feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:48 +02:00
1 parent 6331c88911
commit 1fa345edd1
165 files changed
+3698 -1649

No files matched your search

@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{error.message}
{tCommon("action.tryAgain")}
</Alert>
);
}
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
return (
<Table.Tr>
<Table.Td>
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
@@ -271,19 +271,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to save token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to delete token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { supportsAdvancedFocus } from "./definition";
import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
"coolify",
"dnsHoleControls",
"dnsHoleSummary",
"iframe",
"indexerManager",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"minecraftServerStatus",
"networkControllerSummary",
"notebook",
"notifications",
"paperlessNgx",
"patchmon",
"releases",
"rssFeed",
"stockPrice",
"systemDisks",
"video",
"vpn",
] as const;
describe("advanced focus support", () => {
test("is reserved for widgets with a distinct advanced experience", () => {
const actualCompactOnlyKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
.map(([kind]) => kind)
.toSorted();
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
});
});
@@ -18,6 +18,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 actionTargetClasses from "../common/action-target.module.css";
import "react-quill-new/dist/quill.snow.css";
@@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const locale = useCurrentIntlLocale();
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const note = getUsableWidgetQueryData(noteQuery);
const { refetch } = noteQuery;
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
+90 -84
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeApplicationUrl(app?.href);
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
app && href
? [
{
id: app.id,
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
interaction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
href,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, options.openInNewTab],
[app, href, options.openInNewTab],
);
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
href={app.href ?? undefined}
openInNewTab={options.openInNewTab}
enabled={Boolean(app.href) && !isEditMode}
>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
<Box h="100%" w="100%" pos="relative">
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? "xs" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && !isTiny && (
<Text
className="app-description"
size="sm"
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
</Box>
)}
</Box>
);
}
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
rel="noreferrer"
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
h="100%"
w="100%"
>
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
+11 -16
View File
@@ -36,19 +36,14 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (error && !badges) throw error;
if (!badges) return <WidgetEmptyState />;
const statValues = {
@@ -59,10 +54,10 @@ export default function BazarrWidget({
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
const gridCols = getGridCols(width, height, visibleStatKeys.length);
const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
@@ -100,14 +95,14 @@ export default function BazarrWidget({
);
}
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
export function getGridCols(width: number, height: number, itemCount: number): number {
if (itemCount <= 1) return 1;
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
const rowHeight = height / rowsAtWidth;
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
}
export function getIconSize(width: number): number {
+1
View File
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: false,
icon: IconBadgeCc,
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
+2 -2
View File
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(2);
expect(getGridCols(220, 100, 4)).toBe(1);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4, true)).toBe(4);
expect(getGridCols(800, 400, 4)).toBe(2);
});
test("sizes icons from the constrained dimension", () => {
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
if (alertsError) throw alertsError;
if (isPending) {
return (
<Center h="100%">
@@ -37,6 +37,7 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
@@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({
isEditMode,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending } = systemsQuery;
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({
);
}
const advanced = displayMode === "advanced";
const cols = advanced
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
: getColCount(width, height, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
const minimumCellHeight = MIN_CELL_HEIGHT;
const scrollEnabled = rawCellHeight < minimumCellHeight;
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = advanced
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
: getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -14,9 +14,10 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -25,12 +26,10 @@ export default function BeszelSystemStatsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode = "compact",
boardId,
itemId,
setOptions,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -45,22 +44,26 @@ export default function BeszelSystemStatsWidget({
message: t("error.selectionSaveMessage"),
}),
});
const {
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending: systemsPending } = systemsQuery;
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const includeDocker =
(displayMode === "advanced" || height >= 380) &&
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
setWidgetRuntimeQueries(
widgetStateRef,
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const periodOptions = [
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
@@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
<Center h="100%">
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{!isEditMode && displayMode === "advanced" && (
<SegmentedControl
size="xs"
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={[
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
]}
/>
)}
{!isEditMode &&
(width >= 560 ? (
<SegmentedControl
size="xs"
fullWidth
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={periodOptions}
/>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
/>
))}
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
supportsAdvancedFocus: false,
icon: IconChartAreaLine,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
+33 -24
View File
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { formatLocalizedDate } from "../../common/locale";
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
const timeFormats: Record<BeszelTimePeriod, string> = {
"1m": "HH:mm:ss",
"1h": "HH:mm",
"12h": "HH:mm",
"24h": "HH:mm",
"1w": "MMM D",
"30d": "MMM D",
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
"1h": { hour: "numeric", minute: "2-digit" },
"12h": { hour: "numeric", minute: "2-digit" },
"24h": { hour: "numeric", minute: "2-digit" },
"1w": { month: "short", day: "numeric" },
"30d": { month: "short", day: "numeric" },
};
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
if (timePeriod === "1m") {
return { fmt, ordered: records };
}
return { fmt, ordered: [...records].toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
const days = periodDays[timePeriod];
if (!days) return data;
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
const now = dayjs();
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
const result = [...data];
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
if (data.length === 0) return data;
const end = dayjs(
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
rawTime: timestamp.toISOString(),
}
);
@@ -162,17 +167,18 @@ export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((r) => ({
time: fmt(r.created),
rawTime: r.created,
...mapFn(r.stats),
}));
return padTimeGrid(mapped, timePeriod);
}, [systemStats, mapFn, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [systemStats, mapFn, timePeriod, locale]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]);
@@ -212,10 +218,11 @@ export const useDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
);
export const buildDiskChartData = (
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
return padTimeGrid(mapped, timePeriod, locale);
};
export const useDockerChartData = (
@@ -246,12 +254,13 @@ export const useDockerChartData = (
containerNames: string[],
metric: "cpu" | "memory" | "network",
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const byName = new Map(record.stats.map((c) => [c.n, c]));
@@ -260,5 +269,5 @@ export const useDockerChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [containerStats, containerNames, metric, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [containerStats, containerNames, metric, timePeriod, locale]);
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
@@ -71,13 +72,15 @@ export function BeszelStatsView({
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
@@ -125,10 +128,25 @@ export function BeszelStatsView({
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
const memoryData = useSystemChartData(
whenVisible(visibility.memory, systemStats),
mappers.memory,
timePeriod,
locale,
);
const diskIOData = useSystemChartData(
whenVisible(visibility.diskIO, systemStats),
mappers.diskIO,
timePeriod,
locale,
);
const networkData = useSystemChartData(
whenVisible(visibility.network, systemStats),
mappers.network,
timePeriod,
locale,
);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
@@ -138,7 +156,13 @@ export function BeszelStatsView({
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskData = useDiskChartData(
whenVisible(visibility.disk, systemStats),
efsPaths,
rootSeriesName,
timePeriod,
locale,
);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
@@ -161,22 +185,23 @@ export function BeszelStatsView({
containerNames,
"cpu",
timePeriod,
locale,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
locale,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
locale,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
@@ -198,7 +223,7 @@ export function BeszelStatsView({
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
@@ -212,7 +237,7 @@ export function BeszelStatsView({
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
{t("error.internalServerError")}
</Text>
</Stack>
</Center>
+148 -135
View File
@@ -22,6 +22,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
@@ -64,30 +66,36 @@ export default function BookmarksWidget({
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
const data =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data
.filter((app) => app.href !== null)
.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: false,
};
},
})),
data.flatMap((app) => {
const href = getSafeApplicationUrl(app.href);
return href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href,
newTab: false,
};
},
},
]
: [];
}),
[data],
);
@@ -174,47 +182,50 @@ const FlexLayout = ({
const board = useRequiredBoard();
return (
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
))}
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
);
})}
</Flex>
);
};
@@ -231,12 +242,8 @@ export const getAdvancedBookmarkColumns = (width: number, itemCount: number): nu
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
if (!href) return undefined;
try {
return new URL(href).hostname;
} catch {
return href;
}
const safeHref = getSafeApplicationUrl(href);
return safeHref ? new URL(safeHref).hostname : undefined;
};
const AdvancedBookmarksLayout = ({
@@ -251,41 +258,44 @@ const AdvancedBookmarksLayout = ({
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
</ScrollArea>
);
@@ -327,43 +337,46 @@ const GridLayout = ({
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => (
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
h="100%"
>
<Card
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
))}
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
);
};
@@ -27,7 +27,7 @@ describe("bookmark display helpers", () => {
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
});
@@ -17,9 +17,11 @@ import dayjs from "dayjs";
import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { groupEventsByDate } from "./calendar-events";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { formatLocalizedTime } from "../common/locale";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
@@ -36,7 +38,9 @@ export const CalendarEventList = ({
locale,
}: CalendarEventListProps) => {
const headingIdPrefix = useId();
const dateFormatter = new Intl.DateTimeFormat(locale, {
const currentLocale = useCurrentIntlLocale();
const effectiveLocale = locale ?? currentLocale;
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
weekday: "long",
year: "numeric",
month: "long",
@@ -66,20 +70,20 @@ export const CalendarEventList = ({
{dateFormatter.format(groupedEvents[0]?.startDate)}
</Text>
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} />
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} />
<CalendarEventRows events={events} locale={effectiveLocale} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
@@ -136,14 +140,14 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
{formatLocalizedTime(event.startDate, locale)}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
{formatLocalizedTime(event.endDate, locale)}
</Text>
</>
)}
@@ -171,31 +175,35 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
.map((link) => {
const href = getSafeApplicationUrl(link.href.toString());
return (
<Button
key={link.href}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
);
})}
</Group>
)}
</Stack>
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl } from "./application-url";
describe("getSafeApplicationUrl", () => {
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
"rejects unsafe or non-absolute URL %s",
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
);
test("rejects credential-bearing URLs", () => {
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
});
test("allows absolute HTTP and HTTPS URLs", () => {
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
});
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
"https://example.com/story",
);
});
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
expect(getSafeApplicationUrl(value)).toBeUndefined();
});
});
@@ -0,0 +1 @@
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
const sample = new Date("2026-01-02T13:05:09.000Z");
describe("localized widget formatting", () => {
test.each([
["en-US", /Jan/],
["fr-FR", /janv/i],
["de-DE", /Jan/],
["he-IL", /[\u0590-\u05ff]/],
])("formats dates for %s", (locale, expected) => {
expect(
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
).toMatch(expected);
});
test("supports explicit 12-hour and 24-hour clocks", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
});
test("normalizes binary status for translated accessibility labels", () => {
expect(getBinaryStatusKey("enabled")).toBe("enabled");
expect(getBinaryStatusKey("disabled")).toBe("disabled");
expect(getBinaryStatusKey()).toBe("unknown");
});
});
+61
View File
@@ -0,0 +1,61 @@
export type LocalizedDateValue = Date | number | string;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
timeZone?: string;
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
export const formatLocalizedDate = (
value: LocalizedDateValue,
locale: string,
options: Intl.DateTimeFormatOptions,
fallback = "?",
): string => {
const date = toValidDate(value);
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
};
export const formatLocalizedTime = (
value: LocalizedDateValue,
locale: string,
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{
hour: "numeric",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
hour12,
timeZone,
},
fallback,
);
export const formatLocalizedDateTime = (
value: LocalizedDateValue,
locale: string,
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
fallback,
);
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetQueryErrorIndicator", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
});
@@ -0,0 +1,38 @@
"use client";
import { Box, Center, Loader, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
if (!error) return null;
const accessibleLabel = label;
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<Box
component="span"
c="orange"
role="img"
aria-label={accessibleLabel}
tabIndex={0}
style={{ display: "inline-flex", cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</Box>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
return (
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
<Loader size="sm" />
</Center>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
});
test("preserves stale cached data when a refetch fails", () => {
const data = [{ id: "healthy" }];
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
});
test("keeps the initial pending state when neither data nor an error exists", () => {
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
});
});
describe("isInitialWidgetQueryPending", () => {
test("only marks the unresolved first request as initial pending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
describe("hasStaleWidgetQueryError", () => {
test("only marks failures that retain usable cached data", () => {
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
});
});
@@ -0,0 +1,22 @@
interface WidgetQueryState {
data: unknown;
error: unknown;
}
interface PendingWidgetQueryState extends WidgetQueryState {
isPending: boolean;
}
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
error !== null && error !== undefined && data !== undefined;
+10 -24
View File
@@ -6,17 +6,12 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -27,15 +22,7 @@ export default function CoolifyWidget({
);
}
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
interface CoolifyContentProps {
@@ -43,17 +30,18 @@ interface CoolifyContentProps {
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
const t = useScopedI18n("common");
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
const { isPending } = instancesQuery;
const isTiny = !isAdvanced && (width < 256 || height < 144);
const hideFooter = !isAdvanced && height < 112;
const isTiny = width < 256 || height < 144;
const hideFooter = height < 112;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
@@ -74,7 +62,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
);
@@ -82,7 +69,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
return (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
@@ -90,7 +77,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
))}
+1
View File
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: false,
icon: IconCloud,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -13,6 +13,7 @@ import {
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -59,13 +60,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
? t("status.restarting")
: t("status.unknown");
const resourceUrl =
const resourceUrl = getSafeApplicationUrl(
item.projectUuid && item.environmentUuid
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
: undefined;
: undefined,
);
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
const publicUrl = cleanFqdn(item.fqdn);
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
const resourceTimestamp = getResourceTimestamp(item, resourceType);
return (
@@ -77,7 +79,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
className={actionTargetClasses.root}
href={resourceUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
@@ -98,7 +100,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
component="a"
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openResource", { name: item.name })}
size="xs"
variant="subtle"
@@ -113,7 +115,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
component="a"
href={logsUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openLogs", { name: item.name })}
size="xs"
variant="subtle"
@@ -7,6 +7,7 @@ import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
@@ -92,7 +93,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
const t = useScopedI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
@@ -105,9 +106,10 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
/>
<Anchor
className={actionTargetClasses.root}
component={serverUrl ? "a" : "span"}
href={serverUrl}
target="_blank"
rel="noopener noreferrer"
target={serverUrl ? "_blank" : undefined}
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
c="inherit"
truncate="end"
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
@@ -18,8 +19,8 @@ interface SingleInstanceLayoutProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
@@ -27,8 +28,8 @@ export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
@@ -40,36 +41,15 @@ export function SingleInstanceLayout({
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
return (
@@ -82,17 +62,25 @@ export function SingleInstanceLayout({
oolify
</Text>
</Group>
<Anchor
href={baseUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
<Stack gap={0} miw={0}>
{isAdvanced && (
<Text fz="xs" fw={600} truncate="end">
{instance.integrationName}
</Text>
)}
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
</Anchor>
</Stack>
</Group>
<Accordion
@@ -108,6 +96,7 @@ export function SingleInstanceLayout({
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -116,7 +105,12 @@ export function SingleInstanceLayout({
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
)}
</Accordion>
@@ -415,7 +415,6 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
@@ -77,7 +77,6 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
</WidgetDefinitionProvider>
);
}
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
return (
left.loading === right.loading &&
+2 -19
View File
@@ -1,28 +1,11 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { IconApi } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
contextActions({ options, widgetStateRef }) {
return [
{
key: "togglePolling",
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef?.current?.togglePolling;
if (typeof action === "function") action();
},
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
+28 -25
View File
@@ -1,16 +1,16 @@
import { describe, expect, test } from "vitest";
import { definition as appDefinition } from "./app";
import { definition as beszelAlertsDefinition } from "./beszel-alerts";
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
import { definition as beszelSystemStatsDefinition } from "./beszel-system-stats";
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
setWidgetRuntimeQueries,
supportsAdvancedFocus,
} from "./definition";
const togglePolling = () => undefined;
import { definition as clockDefinition } from "./clock";
import { definition as customApiDefinition } from "./custom-api";
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
import { definition as dockerDefinition } from "./docker";
import { definition as downloadsDefinition } from "./downloads";
import { definition as indexerManagerDefinition } from "./indexer-manager";
import { definition as notebookDefinition } from "./notebook";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -29,23 +29,26 @@ describe("getWidgetQueryKeys", () => {
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
test("registers runtime queries without replacing imperative widget actions", () => {
const widgetStateRef = { current: { togglePolling } };
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
});
});
describe("supportsAdvancedFocus", () => {
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
"keeps non-enhanced widgets compact",
(widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
},
);
test.each([
appDefinition,
beszelAlertsDefinition,
beszelSystemGridDefinition,
beszelSystemStatsDefinition,
beszelSystemTableDefinition,
clockDefinition,
customApiDefinition,
dockerDefinition,
indexerManagerDefinition,
notebookDefinition,
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
});
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
@@ -33,6 +33,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -46,7 +47,6 @@ export default function DnsHoleControlsWidget({
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -54,9 +54,9 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending: isSummaryPending } = summaryQuery;
const utils = clientApi.useUtils();
const {
@@ -118,8 +118,7 @@ export default function DnsHoleControlsWidget({
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible =
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
const actionError = enableError ?? disableError;
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
@@ -229,7 +228,6 @@ export default function DnsHoleControlsWidget({
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
@@ -242,7 +240,7 @@ export default function DnsHoleControlsWidget({
</Text>
)}
{actionError && (
<Tooltip label={actionError.message} multiline>
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
</Text>
@@ -269,7 +267,6 @@ interface ControlsCardProps {
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
@@ -283,7 +280,6 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
@@ -294,7 +290,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
return (
<Indicator
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
@@ -2,7 +2,19 @@
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Badge, Card, Flex, Group, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
import {
Avatar,
AvatarGroup,
Badge,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
@@ -18,20 +30,25 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
displayMode,
width,
height,
}: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
if (isPending) {
return (
@@ -74,18 +91,21 @@ export default function DnsHoleSummaryWidget({
</Stack>
)}
</SimpleGrid>
{displayMode === "advanced" && summaries.length > 0 && (
<Group px="xs" pb="xs" gap="xs">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
</Badge>
))}
</Group>
{showSourceStatuses && (
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
<Group gap="xs" wrap="nowrap">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
style={{ flexShrink: 0 }}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
</Badge>
))}
</Group>
</ScrollArea>
)}
</Stack>
);
@@ -174,7 +194,7 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
className="summary-card"
p="sm"
radius={board.itemRadius}
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
style={{
flex: 1,
}}
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetError } from "./component";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetError", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("never renders an unmapped backend error", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetError, {
kind: "app",
error: new Error(secret),
resetErrorBoundary: vi.fn(),
}),
),
),
);
expect(host.textContent).toContain("common.error");
expect(host.textContent).not.toContain(secret);
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
});
+9 -2
View File
@@ -28,7 +28,14 @@ export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErr
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
}
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(t) => t("common.error")}
showLogsLink
onRetry={resetErrorBoundary}
/>
);
};
const handleWidgetTrpcError = (
@@ -60,7 +67,7 @@ const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRe
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
return {
icon: IconShield,
message: "You don't have permission to access this widget",
message: (t) => t("integration.testConnection.error.certificate.alert.permission.title"),
showLogsLink: false,
};
}
+27 -19
View File
@@ -44,7 +44,8 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
queries.every((query) => !query.isPending && !query.isFetching) &&
queries.every(
(query) =>
query.isError || (query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
(query.isError && query.data === undefined) ||
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
);
export default function FirewallWidget({
@@ -58,7 +59,7 @@ export default function FirewallWidget({
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || height >= 120;
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
@@ -159,7 +160,6 @@ export default function FirewallWidget({
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue}
@@ -190,7 +190,6 @@ interface FirewallPanelProps {
interfacesError: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
ringSize: number;
showInterfaces: boolean;
accordionValue: string | null;
@@ -214,7 +213,6 @@ const FirewallPanel = ({
interfacesError,
hasError,
isAdvanced,
isTiny,
ringSize,
showInterfaces,
accordionValue,
@@ -276,7 +274,6 @@ const FirewallPanel = ({
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
@@ -302,6 +299,8 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
const showIcon = size >= 64;
const showStatus = size >= 96;
return (
<RingProgress
@@ -315,9 +314,9 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
thickness={size < 72 ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={size < 72 ? 8 : 16} />
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
<Text size="xs">{safeValue.toFixed(1)}%</Text>
{showIcon && <Icon size={size < 96 ? 12 : 16} />}
{showStatus && <Text size="xs">{statusLabel}</Text>}
</Center>
}
sections={[
@@ -341,7 +340,6 @@ interface InterfacesPanelProps {
hasResult: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
@@ -356,7 +354,6 @@ const InterfacesPanel = ({
hasResult,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
@@ -374,9 +371,9 @@ const InterfacesPanel = ({
variant={isAdvanced ? "contained" : "default"}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Accordion.Control icon={<IconTopologyBus size={16} />}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
<Text size="xs">{label}</Text>
{hasError && (
<Badge color="red" variant="light" size="xs">
{errorBadgeLabel}
@@ -387,19 +384,30 @@ const InterfacesPanel = ({
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
<Group key={name} gap="xs" wrap="nowrap" justify="space-between">
<Text
size="xs"
c="light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3))"
truncate="end"
style={{ flex: 1 }}
>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
<IconArrowBarUp
size={12}
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
/>
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
<IconArrowBarDown
size={12}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
/>
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasTotalFirewallFailure } from "./component";
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
const failedQuery = {
isPending: false,
@@ -27,4 +27,31 @@ describe("firewall failure state", () => {
false,
);
});
it("preserves stale cached data when a query-wide refetch fails", () => {
const staleQuery = {
...failedQuery,
isError: true,
data: [{ integration: { id: "firewall-1" }, error: undefined }],
};
expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false);
});
it("surfaces query-wide and integration-specific partial failures", () => {
const successfulQuery = {
isPending: false,
isFetching: false,
isError: false,
data: [{ integration: { id: "firewall-1" }, error: undefined }],
};
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
expect(
hasFirewallPartialFailure("firewall-1", [
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
]),
).toBe(true);
});
});
@@ -7,6 +7,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault } from "./accordion-state";
@@ -38,9 +39,9 @@ export const ClusterHealthMonitoring = ({
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
const healthData = getUsableWidgetQueryData(
clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }),
);
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
@@ -67,9 +68,9 @@ export const ClusterHealthMonitoring = ({
const isTiny = displayMode !== "advanced" && width < 256;
return (
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
{options.showUptime && !isTiny && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
<Text fz="xs" fw={700} c="dimmed" ta="center">
{formatUptime(uptime, t)}
</Text>
</Group>
@@ -190,10 +191,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text fw={500} size={isTiny ? "xs" : "sm"}>
{t("widget.healthMonitoring.cluster.summary.cpu")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
{!isTiny && (
<Text fw={500} size="sm">
{t("widget.healthMonitoring.cluster.summary.cpu")}
</Text>
)}
<Text size="xs">{cpu.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
@@ -211,10 +214,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
{!isTiny && (
<Text size="sm" fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
</Text>
)}
<Text size="xs">{memory.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
@@ -27,21 +27,21 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
return (
<Table highlightOnHover>
<TableThead>
<TableTr fz={isTiny ? "8px" : "xs"}>
<TableTr fz="xs">
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.name")}
</TableTh>
{type !== "storage" ? (
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.cpu")}
</TableTh>
) : null}
{type !== "storage" ? (
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.memory")}
</TableTh>
) : null}
{type === "storage" ? (
{!isTiny && type === "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.node")}
</TableTh>
@@ -59,25 +59,25 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
return (
<ResourcePopover key={item.id} item={item}>
<Popover.Target>
<TableTr fz={isTiny ? "8px" : "xs"}>
<TableTr fz="xs">
<TableTd>
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={item.isRunning ? "green" : "yellow"}>
<Indicator size={isTiny ? 6 : 8} color={item.isRunning ? "green" : "yellow"}>
{null}
</Indicator>
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
<Text lineClamp={1} fz="xs">
{item.name}
</Text>
</Group>
</TableTd>
{item.type === "storage" ? (
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
{isTiny ? null : item.type === "storage" ? (
<TableTd style={{ WebkitLineClamp: "1" }}>{item.node}</TableTd>
) : (
<>
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
<td style={{ whiteSpace: "nowrap" }}>
<TableTd style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</TableTd>
<TableTd style={{ whiteSpace: "nowrap" }}>
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
</td>
</TableTd>
</>
)}
</TableTr>
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
import { SystemHealthMonitoring } from "./system-health";
@@ -15,7 +16,7 @@ import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const integrations = getUsableWidgetQueryData(clientApi.integration.byIds.useQuery(props.integrationIds)) ?? [];
const t = useI18n();
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
@@ -41,6 +41,7 @@ import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
@@ -59,9 +60,9 @@ export const SystemHealthMonitoring = ({
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const healthData =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
[];
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
const board = useRequiredBoard();
+9 -22
View File
@@ -1,43 +1,35 @@
"use client";
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!isSupportedProtocol(embedUrl)) {
if (!safeEmbedUrl) {
return <UnsupportedProtocol />;
}
return (
<Stack h="100%" w="100%" gap={0}>
{displayMode === "advanced" && (
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
<Text size="xs" c="dimmed" truncate>
{getFrameTitle(embedUrl)}
</Text>
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
{t("widget.app.option.openInNewTab.label")}
</Anchor>
</Group>
)}
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
<Box style={{ flex: 1, minHeight: 0 }}>
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={getFrameTitle(embedUrl)}
src={safeEmbedUrl}
title={getFrameTitle(safeEmbedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -52,12 +44,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
} catch {
return false;
}
return getSafeApplicationUrl(url) !== undefined;
};
const NoUrl = () => {
+31 -15
View File
@@ -1,23 +1,39 @@
import { IconBrowser } from "@tabler/icons-react";
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
supportsAdvancedFocus: false,
icon: IconBrowser,
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
contextActions: ({ options }) => {
const embedUrl = getSafeApplicationUrl(options.embedUrl);
return embedUrl
? [
{
key: "open-iframe",
label: "widget.app.option.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
]
: [];
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import {
@@ -18,6 +18,8 @@ import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translatio
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { useWidgetRuntimeActions } from "../../runtime-hooks";
import { getUsableWidgetQueryData } from "../../common/query-state";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
@@ -25,17 +27,19 @@ export default function ImmichAlbumCarouselWidget({
integrationIds,
options,
displayMode = "compact",
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { data: album } = 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(
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 photoAssets = useMemo(() => {
@@ -48,30 +52,19 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
}, [photoAssets.length]);
useEffect(() => {
if (!widgetStateRef) return;
const clearActions = () => {
if (!widgetStateRef.current) return;
delete widgetStateRef.current.previousPhoto;
delete widgetStateRef.current.nextPhoto;
delete widgetStateRef.current.toggleSlideshow;
};
if (photoAssets.length <= 1) {
clearActions();
return;
}
const previousPhoto = () =>
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
const toggleSlideshow = () => setPaused((value) => !value);
widgetStateRef.current = {
...widgetStateRef.current,
previousPhoto,
nextPhoto,
toggleSlideshow,
};
return clearActions;
}, [photoAssets.length, widgetStateRef]);
const previousPhoto = useCallback(
() => setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length),
[photoAssets.length],
);
const nextPhoto = useCallback(
() => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length),
[photoAssets.length],
);
const toggleSlideshow = useCallback(() => setPaused((value) => !value), []);
useWidgetRuntimeActions(
widgetRuntimeRef,
photoAssets.length > 1 ? { previousPhoto, nextPhoto, toggleSlideshow } : {},
);
if (!album) return <WidgetEmptyState />;
@@ -8,6 +8,47 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
const createOptions = () =>
optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(3600),
withDescription: true,
}),
showPhotoInfo: factory.switch({
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
queryKey: [["widget", "immich", "getAlbum"]],
@@ -23,75 +64,31 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef?.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
contextActions: ({ widgetRuntimeRef }) => {
const actions = widgetRuntimeRef.current.actions;
return [
{
key: "previousPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
label: "widget.immich-albumCarousel.actions.previousPhoto",
icon: IconChevronLeft,
disabled: disabled("previousPhoto"),
onClick: call("previousPhoto"),
disabled: typeof actions.previousPhoto !== "function",
onClick: () => actions.previousPhoto?.(),
},
{
key: "nextPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
label: "widget.immich-albumCarousel.actions.nextPhoto",
icon: IconChevronRight,
disabled: disabled("nextPhoto"),
onClick: call("nextPhoto"),
disabled: typeof actions.nextPhoto !== "function",
onClick: () => actions.nextPhoto?.(),
},
{
key: "toggleSlideshow",
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
label: "widget.immich-albumCarousel.actions.toggleSlideshow",
icon: IconPlayerPause,
disabled: disabled("toggleSlideshow"),
onClick: call("toggleSlideshow"),
disabled: typeof actions.toggleSlideshow !== "function",
onClick: () => actions.toggleSlideshow?.(),
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(3600),
withDescription: true,
}),
showPhotoInfo: factory.switch({
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -1,4 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { WidgetComponentProps } from "../definition";
import ImmichServerStatsWidget from "./server-stats/component";
@@ -26,20 +31,52 @@ vi.mock("@homarr/translation/client", () => ({
}));
describe("Immich album queries", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
beforeEach(() => vi.clearAllMocks());
test("server stats requests a bounded album response", () => {
ImmichServerStatsWidget({
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">);
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("server stats requests a bounded album response", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(ImmichServerStatsWidget, {
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">),
),
),
);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id", limit: 50 },
@@ -2,11 +2,6 @@
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: all 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.statItemDense {
@@ -1,8 +1,9 @@
"use client";
import React from "react";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { formatBytes } from "@homarr/common";
@@ -10,7 +11,9 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { setWidgetRuntimeQueries } from "../../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
import classes from "./component.module.css";
const MAX_ADVANCED_ALBUMS = 50;
@@ -21,7 +24,7 @@ export default function ImmichServerStatsWidget({
displayMode = "compact",
width,
height,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const locale = useCurrentIntlLocale();
@@ -29,25 +32,32 @@ export default function ImmichServerStatsWidget({
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
const isAdvanced = displayMode === "advanced";
const albumsEnabled = isAdvanced && integrationIds.length > 0;
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
const statsQuery = clientApi.widget.immich.getServerStats.useQuery(input);
const stats = getUsableWidgetQueryData(statsQuery);
const albumsQuery = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
enabled: albumsEnabled,
staleTime: 15 * 60 * 1000,
});
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "immich", "getServerStats"], input },
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
const albums = albumsQuery.data ?? [];
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.immich.getServerStats, input, "query"),
...(albumsEnabled ? [getQueryKey(clientApi.widget.immich.getAlbums, albumsInput, "query")] : []),
]);
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
if (!stats) return <WidgetEmptyState />;
const statVisibility = getImmichStatVisibility(options, isAdvanced);
const statCount =
Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage);
Number(statVisibility.showUsers) +
Number(statVisibility.showPhotos) +
Number(statVisibility.showVideos) +
Number(statVisibility.showStorage);
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{options.showUsers && (
{statVisibility.showUsers && (
<StatItem
icon={<IconUsers size={20} />}
label={t("widget.immich-serverStats.users")}
@@ -55,7 +65,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showPhotos && (
{statVisibility.showPhotos && (
<StatItem
icon={<IconPhoto size={20} />}
label={t("widget.immich-serverStats.photos")}
@@ -63,7 +73,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showVideos && (
{statVisibility.showVideos && (
<StatItem
icon={<IconVideo size={20} />}
label={t("widget.immich-serverStats.videos")}
@@ -71,7 +81,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showStorage && (
{statVisibility.showStorage && (
<StatItem
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
@@ -84,8 +94,13 @@ export default function ImmichServerStatsWidget({
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center">
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
{statsQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("widget.immich-serverStats.name")} />
</Box>
)}
</Stack>
);
}
@@ -95,22 +110,41 @@ export default function ImmichServerStatsWidget({
return (
<Stack gap="lg" h="100%" p="lg">
{statsContent}
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("widget.immich-serverStats.albumLimit", { count: MAX_ADVANCED_ALBUMS })}
</Text>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? albumsQuery.error}
label={t("widget.immich-serverStats.name")}
/>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString(locale)}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
{isInitialWidgetQueryPending(albumsQuery) ? (
<WidgetQueryLoadingState />
) : albumsQuery.error && albumsQuery.data === undefined ? (
<Center h="100%" p="md">
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("widget.immich-serverStats.name")} />
</Center>
) : albums.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString(locale)}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
)}
</ScrollArea>
</Stack>
);
@@ -163,3 +197,13 @@ export const getImmichStatsLayout = (width: number, height: number, itemCount: n
if (height < 220) return { columns: Math.min(count, 2), dense: true };
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
};
export const getImmichStatVisibility = (
options: WidgetComponentProps<"immich-serverStats">["options"],
isAdvanced: boolean,
) => ({
showUsers: isAdvanced || options.showUsers,
showPhotos: isAdvanced || options.showPhotos,
showVideos: isAdvanced || options.showVideos,
showStorage: isAdvanced || options.showStorage,
});
+4
View File
@@ -74,8 +74,12 @@ export type {
WidgetOptionsSettings,
WidgetQueryMatcher,
WidgetQueryMatcherScope,
WidgetRuntimeActions,
WidgetRuntimeRef,
WidgetRuntimeState,
} from "./definition";
export {
createWidgetRuntimeState,
getWidgetQueryKeys,
getWidgetRuntimeQueries,
normalizeWidgetQuery,
@@ -35,10 +35,23 @@
}
}
.indexerLink {
display: flex;
align-items: center;
align-self: stretch;
min-width: 0;
flex: 1;
}
@media (any-pointer: coarse) {
.testAction {
opacity: 1;
}
.indexerRow {
min-height: 44px;
padding-block: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -10,6 +10,9 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
@@ -18,36 +21,27 @@ export default function IndexerManagerWidget({
integrationIds,
width,
height,
displayMode,
isEditMode,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
const indexersData =
getUsableWidgetQueryData(clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds })) ?? [];
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const hasSmallWidth = width < 256;
const isDense = width < 280 || height < 180;
const showHealthCounts = width >= 200 && height >= 100;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
).length;
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
testAllIndexers: () => testAll({ integrationIds }),
};
return () => {
if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
};
}, [integrationIds, testAll, widgetStateRef]);
useWidgetRuntimeActions(widgetRuntimeRef, { testAllIndexers: () => testAll({ integrationIds }) });
return (
<Flex
@@ -70,7 +64,7 @@ export default function IndexerManagerWidget({
{t("widget.indexerManager.title")}
</Text>
)}
{isAdvanced && (
{showHealthCounts && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
@@ -110,64 +104,61 @@ export default function IndexerManagerWidget({
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1) && (
{indexersData.length > 1 && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Tooltip label={error} multiline maw={360}>
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
</Tooltip>
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
)}
</Group>
)}
{indexers.map((indexer) => (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className="indexer-manager-line-anchor"
href={indexer.url}
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
rel={options.openIndexerSiteInNewTab ? "noopener noreferrer" : undefined}
style={{ minWidth: 0, flex: 1 }}
{indexers.map((indexer) => {
const href = getSafeApplicationUrl(indexer.url);
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
title={href}
>
{indexer.name}
</Text>
</Anchor>
{isAdvanced && (
<Text size="xs" c="dimmed" truncate="end" style={{ flex: 1 }}>
{indexer.url}
</Text>
)}
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="#d9534f"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="#2ecc71"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
))}
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
);
})}
</Stack>
))}
</ScrollArea>
+13 -12
View File
@@ -5,31 +5,32 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
supportsAdvancedFocus: false,
icon: IconReportSearch,
contextActions: ({ widgetStateRef, context }) => [
contextActions: ({ widgetRuntimeRef, context }) => [
{
key: "test-all-indexers",
label: (t) => t("widget.indexerManager.testAll"),
label: "widget.indexerManager.testAll",
icon: IconTestPipe,
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
typeof widgetStateRef?.current?.testAllIndexers !== "function",
typeof widgetRuntimeRef.current.actions.testAllIndexers !== "function",
onClick: () => {
const action = widgetStateRef?.current?.testAllIndexers;
if (typeof action === "function") action();
widgetRuntimeRef.current.actions.testAllIndexers?.();
},
},
],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
},
createOptions,
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
errors: {
INTERNAL_SERVER_ERROR: {
@@ -19,14 +19,18 @@ import {
Tooltip,
} from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs";
@@ -38,20 +42,23 @@ export default function MediaMissingWidget({
width,
height,
displayMode,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
const showQueued = isAdvanced || options.showQueued;
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const input = { integrationIds, pageSize };
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
const data = getUsableWidgetQueryData(mediaQuery);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
useEffect(() => {
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
@@ -59,7 +66,7 @@ export default function MediaMissingWidget({
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
if (!options.showMissing && !options.showQueued)
if (!showMissing && !showQueued)
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
@@ -74,7 +81,7 @@ export default function MediaMissingWidget({
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
@@ -97,7 +104,12 @@ export default function MediaMissingWidget({
) : (
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => (
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
<MediaCard
key={`${integrationId}-${item.type}-${item.id}`}
item={item}
density={density}
showQueueDetails={isAdvanced}
/>
))}
</SimpleGrid>
)}
@@ -108,7 +120,7 @@ export default function MediaMissingWidget({
const partialFailures = failedIntegrations.length > 0 && (
<Group gap={4} p={4} wrap="wrap">
{failedIntegrations.map((entry) => (
<Tooltip key={entry.integrationId} label={entry.error} multiline maw={360}>
<Tooltip key={entry.integrationId} label={`${entry.integrationName}: ${t("name")}`}>
<Badge size="xs" color="red" variant="light">
{entry.integrationName}
</Badge>
@@ -116,13 +128,17 @@ export default function MediaMissingWidget({
))}
</Group>
);
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
{partialFailures}
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{options.showMissing && (
{showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconQuestionMark size={16} />
@@ -133,7 +149,7 @@ export default function MediaMissingWidget({
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
</Paper>
)}
{options.showQueued && (
{showQueued && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconDownload size={16} />
@@ -158,26 +174,29 @@ export default function MediaMissingWidget({
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
{partialFailures}
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
<Tabs.List grow>
{options.showMissing && (
{showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{options.showQueued && (
{showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab>
)}
</Tabs.List>
{options.showMissing && (
{showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{options.showQueued && (
{showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
@@ -283,22 +302,33 @@ const CardShell = ({
item: MissingMediaItem | QueuedMediaItem;
density: Density;
children: ReactNode;
}) => (
<Paper
className={classes.card}
component="a"
href={item.link}
target="_blank"
rel="noreferrer"
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
>
<div className={classes.content}>{children}</div>
</Paper>
);
}) => {
const href = getSafeApplicationUrl(item.link);
return (
<Paper
className={classes.card}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
>
<div className={classes.content}>{children}</div>
</Paper>
);
};
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
const MediaCard = ({
item,
density,
showQueueDetails,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
showQueueDetails: boolean;
}) => {
const isQueued = "percentComplete" in item;
return (
@@ -315,6 +345,11 @@ const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem
{item.type === "episode" ? item.title : item.year}
</Text>
)}
{isQueued && showQueueDetails && (
<Text fz="10px" c="dimmed" lineClamp={1} lh={1.1}>
{[item.status, item.timeLeft].filter(Boolean).join(" · ")}
</Text>
)}
</Stack>
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
</Group>
@@ -28,7 +28,11 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import classes from "./component.module.css";
export default function MediaReleasesWidget({
@@ -38,23 +42,33 @@ export default function MediaReleasesWidget({
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const t = useI18n();
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const releases = getUsableWidgetQueryData(releasesQuery);
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
if (!releases || releases.length === 0) return <WidgetEmptyState />;
const isAdvanced = displayMode === "advanced";
return (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
<Box h="100%" pos="relative">
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
{releasesQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
</Box>
)}
</Box>
);
}
@@ -86,6 +100,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
const href = getSafeApplicationUrl(item.href);
return (
<Tooltip
@@ -102,10 +117,10 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
>
<UnstyledButton
className={classes.item}
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
h="100%"
@@ -14,6 +14,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
@@ -27,25 +29,27 @@ export default function MediaServerWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id))
.map(({ id }) => id),
);
const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
});
const mediaRequestData = getUsableWidgetQueryData(
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}),
);
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return (
<Stack className={searchClasses.searchRoot} gap={0}>
@@ -65,9 +69,9 @@ export default function MediaServerWidget({
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
isDense={displayMode !== "advanced" && (width < 340 || height < 150)}
isAdvanced={displayMode === "advanced"}
isTiny={width <= 256 || height < 96}
isDense={width < 340 || height < 150}
showIntegrationSource={showIntegrationSource}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
/>
@@ -100,14 +104,23 @@ interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isDense: boolean;
isAdvanced: boolean;
showIntegrationSource: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const MediaRequestCard = ({
request,
isTiny,
isDense,
showIntegrationSource,
canInteract,
options,
}: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href);
const requestedByHref = getSafeApplicationUrl(request.requestedBy?.link);
return (
<Card
@@ -148,10 +161,11 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={request.href}
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={600}
title={request.name}
@@ -186,7 +200,7 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
{t(`availability.${request.availability}`)}
</Badge>
)}
{isAdvanced && (
{showIntegrationSource && !isTiny && (
<Badge size="xs" variant="outline">
{request.integration.name}
</Badge>
@@ -201,13 +215,14 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={request.requestedBy?.link}
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="dimmed"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
truncate="end"
style={{ minWidth: 0, maxWidth: isAdvanced ? 180 : 100 }}
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
@@ -252,7 +267,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-buttons ${classes.pendingActions} ${alwaysVisible ? classes.pendingActionsVisible : ""}`}
gap={4}
wrap="nowrap"
title={error?.message}
aria-invalid={Boolean(error)}
>
<Tooltip label={t("pending.approve")}>
<ActionIcon
@@ -8,8 +8,26 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
const createOptions = () =>
optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
statusFilter: factory.multiSelect({
defaultValue: [...mediaRequestStatusValues],
options: mediaRequestStatusValues.map((value) => ({
value,
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
})),
}),
recentDays: factory.number({
validate: z.number().min(0).max(365),
defaultValue: 0,
}),
}));
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
supportsAdvancedFocus: false,
icon: IconZoomQuestion,
queryKey: [["widget", "mediaRequests", "getLatestRequests"]],
queryMatcher: ({ input }, scope) =>
@@ -21,28 +39,11 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
: mediaRequestStatusValues,
recentDays: scope.options.recentDays,
}),
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
statusFilter: factory.multiSelect({
defaultValue: [...mediaRequestStatusValues],
options: mediaRequestStatusValues.map((value) => ({
value,
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
})),
}),
recentDays: factory.number({
validate: z.number().min(0).max(365),
defaultValue: 0,
}),
}));
},
createOptions,
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
label: "search.mode.media.action.search.label",
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
@@ -22,6 +22,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
@@ -37,12 +39,9 @@ export default function MediaServerWidget({
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
integrationIds,
});
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
const board = useRequiredBoard();
@@ -97,13 +96,10 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
const showSectionTitles = displayMode === "advanced" || height >= 180;
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
const visibleUsers = requestStats.users.slice(
0,
displayMode === "advanced" ? 50 : Math.max(1, Math.floor(height / 44) - 3),
);
const isTiny = width < 256 || height < 180;
const showSectionTitles = height >= 180;
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
return (
<Box className={searchClasses.searchRoot}>
@@ -118,7 +114,7 @@ export default function MediaServerWidget({
p="sm"
align="center"
justify="space-between"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
>
<Stack gap={4} w="100%">
{showSectionTitles && (
@@ -147,60 +143,63 @@ export default function MediaServerWidget({
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Stack gap={4} w="100%" style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
{showSectionTitles && (
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
)}
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%">
<Stack gap={4}>
{visibleUsers.map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
classes.userCard,
)}
key={`${user.integration.id}:${user.id}`}
p="xs"
radius={board.itemRadius}
>
<Group
className="mediaRequests-stats-users-user-group"
h="100%"
p={0}
gap="sm"
justify="space-between"
{requestStats.users.map((user) => {
const href = getSafeApplicationUrl(user.link);
return (
<Card
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
classes.userCard,
)}
key={`${user.integration.id}:${user.id}`}
p="xs"
radius={board.itemRadius}
>
<Group gap={4} wrap="nowrap" miw={0}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
{user.displayName}
<Group
className="mediaRequests-stats-users-user-group"
h="100%"
p={0}
gap="sm"
justify="space-between"
>
<Group gap={4} wrap="nowrap" miw={0}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
{user.displayName}
</Text>
</Group>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
{user.requestCount}
</Text>
</Group>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
{user.requestCount}
</Text>
</Group>
</Card>
))}
</Card>
);
})}
</Stack>
</ScrollArea>
{displayMode === "advanced" && (
{showIntegrationLegend && (
<Group justify="center" gap={4}>
{requestStats.integrations.map((integration) => (
<Badge key={integration.id} size="xs" variant="light">
@@ -5,16 +5,17 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
const createOptions = () => ({});
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
supportsAdvancedFocus: false,
icon: IconChartBar,
queryKey: [["widget", "mediaRequests", "getStats"]],
createOptions() {
return {};
},
createOptions,
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
label: "search.mode.media.action.search.label",
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
@@ -2,7 +2,9 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Center,
Divider,
Group,
@@ -24,7 +26,9 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
@@ -46,7 +50,7 @@ export default function MediaTranscodingWidget({
width,
height,
displayMode,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"mediaTranscoding">) {
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
@@ -61,8 +65,11 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: requestPagination.page,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
]);
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
@@ -89,10 +96,17 @@ export default function MediaTranscodingWidget({
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && (width < 280 || height < 140);
const footerLayout = getTranscodingFooterLayout(width, height);
const queryIndicator = (
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
</Box>
);
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs">
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
@@ -106,8 +120,15 @@ export default function MediaTranscodingWidget({
</AdvancedPanel>
</SimpleGrid>
</ScrollArea>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
<Group gap="xs" justify={queuePageCount > 1 ? "space-between" : "flex-end"} wrap="nowrap">
{queuePageCount > 1 && (
<QueuePaginationControls
total={queuePageCount}
value={queuePage}
onChange={handleQueuePageChange}
layout={footerLayout}
/>
)}
<HealthCheckStatus statistics={transcodingData.data.statistics} />
</Group>
</Stack>
@@ -115,7 +136,8 @@ export default function MediaTranscodingWidget({
}
return (
<Stack gap={4} h="100%">
<Stack gap={4} h="100%" pos="relative">
{queryIndicator}
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
@@ -124,7 +146,7 @@ export default function MediaTranscodingWidget({
<StatisticsPanel statistics={transcodingData.data.statistics} />
)}
<Divider />
<Group gap="xs" mb={4} ms={4} me={8}>
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
<SegmentedControl
data={views.map((value) => {
const Icon = viewIcons[value];
@@ -132,12 +154,12 @@ export default function MediaTranscodingWidget({
label: (
<Center style={{ gap: 4 }}>
<Icon size={12} />
{isTiny ? (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
) : (
{footerLayout.showTabLabels ? (
<Text span size="xs">
{t(`tab.${value}`)}
</Text>
) : (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
)}
</Center>
),
@@ -152,26 +174,27 @@ export default function MediaTranscodingWidget({
}
}}
size="xs"
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }}
/>
<Group gap="xs" ml="auto">
{view === "queue" && (
<Group gap="xs" ml="auto" wrap="nowrap">
{view === "queue" && queuePageCount > 1 && (
<>
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
<Group gap={2} justify="center">
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
<Pagination.Previous disabled={queuePage === 1} />
<Pagination.Next disabled={queuePage === queuePageCount} />
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
</Group>
</Pagination.Root>
<Text size="xs">
{t("currentIndex", {
start: String(transcodingData.data.queue.startIndex + 1),
end: String(transcodingData.data.queue.endIndex + 1),
total: String(transcodingData.data.queue.totalCount),
})}
</Text>
<QueuePaginationControls
total={queuePageCount}
value={queuePage}
onChange={handleQueuePageChange}
layout={footerLayout}
/>
{footerLayout.showPageRange && (
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("currentIndex", {
start: String(transcodingData.data.queue.startIndex + 1),
end: String(transcodingData.data.queue.endIndex + 1),
total: String(transcodingData.data.queue.totalCount),
})}
</Text>
)}
</>
)}
@@ -187,6 +210,32 @@ export const getQueuePageSize = (height: number, isAdvanced: boolean): number =>
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
};
export const getTranscodingFooterLayout = (width: number, height: number) => ({
showTabLabels: width >= 420 && height >= 160,
showPageEdges: width >= 360,
showPageItems: width >= 600,
showPageRange: width >= 760,
});
interface QueuePaginationControlsProps {
total: number;
value: number;
onChange: (page: number) => void;
layout: ReturnType<typeof getTranscodingFooterLayout>;
}
const QueuePaginationControls = ({ total, value, onChange, layout }: QueuePaginationControlsProps) => (
<Pagination.Root total={total} value={value} onChange={onChange} size="xs">
<Group gap={2} justify="center" wrap="nowrap">
{layout.showPageEdges && <Pagination.First disabled={value === 1} />}
<Pagination.Previous disabled={value === 1} />
{layout.showPageItems && <Pagination.Items />}
<Pagination.Next disabled={value === total} />
{layout.showPageEdges && <Pagination.Last disabled={value === total} />}
</Group>
</Pagination.Root>
);
interface QueuePaginationState {
page: number;
pageSize: number;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getQueuePageSize, resolveQueuePagination } from "./component";
import { getQueuePageSize, getTranscodingFooterLayout, resolveQueuePagination } from "./component";
import { toRingProgressSections } from "./panels/statistics.panel";
describe("media transcoding layout", () => {
@@ -14,6 +14,23 @@ describe("media transcoding layout", () => {
expect(getQueuePageSize(200, true)).toBe(25);
});
test("reveals footer detail only as space becomes available", () => {
expect(getTranscodingFooterLayout(280, 220)).toEqual({
showTabLabels: false,
showPageEdges: false,
showPageItems: false,
showPageRange: false,
});
expect(getTranscodingFooterLayout(600, 220)).toEqual({
showTabLabels: true,
showPageEdges: true,
showPageItems: true,
showPageRange: false,
});
expect(getTranscodingFooterLayout(760, 220).showPageRange).toBe(true);
expect(getTranscodingFooterLayout(760, 120).showTabLabels).toBe(false);
});
test("resets the queue when display mode or page size changes", () => {
const compact = { page: 7, pageSize: 10, isAdvanced: false };
@@ -14,9 +14,8 @@ export default function MinecraftServerStatusWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result, isPending } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n();
if (isPending) {
@@ -28,14 +27,16 @@ export default function MinecraftServerStatusWidget({
</Flex>
);
}
if (error && !result) throw error;
if (!result) return <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 220 || height < 120);
const showServerIcon = isAdvanced || (!isDense && height >= 144);
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
const isDense = width < 220 || height < 120;
const showServerIcon = !isDense && height >= 144;
const showMetadata = width >= 260 && height >= 180;
const showCapacity = width >= 180 && height >= 140;
const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45));
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
return (
@@ -44,7 +45,7 @@ export default function MinecraftServerStatusWidget({
h="100%"
w="100%"
direction="column"
p={isAdvanced ? "lg" : isDense ? "xs" : "sm"}
p={isDense ? "xs" : "sm"}
justify="center"
align="center"
>
@@ -63,11 +64,11 @@ export default function MinecraftServerStatusWidget({
? t("widget.minecraftServerStatus.status.online")
: t("widget.minecraftServerStatus.status.offline")}
</VisuallyHidden>
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
<Text size={showMetadata ? "lg" : "md"} fw="bold" truncate="end">
{title}
</Text>
</Group>
{isAdvanced && (
{showMetadata && (
<Group gap="xs" mt="xs" wrap="wrap" justify="center">
<Badge variant="light">{options.domain}</Badge>
{options.isBedrockServer && (
@@ -97,18 +98,18 @@ export default function MinecraftServerStatusWidget({
<IconCube size={iconSize} color="var(--mantine-color-gray-5)" />
</Box>
))}
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
<Stack gap={4} w={showCapacity ? "min(100%, 420px)" : "auto"} align="stretch">
<Group gap={5} c="dimmed" align="center" justify="center">
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
<Text size={isAdvanced ? "lg" : isDense ? "sm" : "md"}>
<IconUsersGroup size={showMetadata ? "1.25rem" : "1rem"} />
<Text size={showMetadata ? "lg" : isDense ? "sm" : "md"}>
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
</Text>
</Group>
{isAdvanced && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
{showCapacity && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
</Stack>
</>
)}
{isAdvanced && !data.online && (
{showMetadata && !data.online && (
<Text mt="md" c="dimmed" size="sm">
{options.domain}
</Text>
@@ -5,6 +5,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
supportsAdvancedFocus: false,
icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft", "getServerStatus"]],
queryMatcher: ({ input }, scope) =>
@@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
@@ -36,12 +37,11 @@ export default function NetworkControllerNetworkStatusWidget({
const tCommon = useScopedI18n("common");
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]);
const isAdvanced = displayMode === "advanced";
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 useHorizontalStats = !isAdvanced && height < 150 && width >= 200;
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
if (error && summaries === undefined) throw error;
@@ -56,25 +56,25 @@ export default function NetworkControllerNetworkStatusWidget({
}
return (
<Box p={isAdvanced ? "md" : height < 120 ? "xs" : "sm"} h="100%">
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
{(isAdvanced || options.content === "wifi") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<Box p={layout.padding} h="100%">
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
{layout.showWifi && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
compact={!isAdvanced}
horizontal={useHorizontalStats}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
</Card>
)}
{(isAdvanced || options.content === "wired") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
{layout.showWired && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
compact={!isAdvanced}
horizontal={useHorizontalStats}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
</Card>
)}
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { getNetworkControllerStatusLayout } from "./layout";
describe("network controller status layout", () => {
test.each([
{
name: "minimum compact",
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
},
{
name: "short compact",
input: { width: 200, height: 149, displayMode: "compact" as const, content: "wired" as const },
expected: { padding: "sm", columns: 1, showWifi: false, showWired: true, horizontalStats: true },
},
{
name: "typical compact",
input: { width: 320, height: 220, displayMode: "compact" as const, content: "wifi" as const },
expected: { padding: "sm", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
},
{
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 },
},
{
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 },
},
{
name: "advanced at 200 percent zoom",
input: { width: 280, height: 320, displayMode: "advanced" as const, content: "wired" as const },
expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false },
},
])("selects the $name tier", ({ input, expected }) => {
expect(getNetworkControllerStatusLayout(input)).toMatchObject(expected);
});
test("keeps breakpoint boundaries explicit", () => {
expect(
getNetworkControllerStatusLayout({ width: 200, height: 150, displayMode: "compact", content: "wifi" }),
).toMatchObject({ padding: "sm", horizontalStats: false });
expect(
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
).toMatchObject({ padding: "xs", horizontalStats: false });
});
});
@@ -0,0 +1,42 @@
import type { WidgetComponentProps } from "../../definition";
type NetworkStatusContent = WidgetComponentProps<"networkControllerStatus">["options"]["content"];
type NetworkStatusDisplayMode = WidgetComponentProps<"networkControllerStatus">["displayMode"];
interface NetworkControllerStatusLayoutInput {
width: number;
height: number;
displayMode: NetworkStatusDisplayMode;
content: NetworkStatusContent;
}
export interface NetworkControllerStatusLayout {
padding: "xs" | "sm" | "md";
columns: 1 | 2;
showWifi: boolean;
showWired: boolean;
cardPadding: 0 | "md";
withBorder: boolean;
compact: boolean;
horizontalStats: boolean;
}
export const getNetworkControllerStatusLayout = ({
width,
height,
displayMode,
content,
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
const isAdvanced = displayMode === "advanced";
return {
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
columns: isAdvanced && width >= 560 ? 2 : 1,
showWifi: isAdvanced || content === "wifi",
showWired: isAdvanced || content === "wired",
cardPadding: isAdvanced ? "md" : 0,
withBorder: isAdvanced,
compact: !isAdvanced,
horizontalStats: !isAdvanced && height < 150 && width >= 200,
};
};
@@ -11,6 +11,8 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { getBinaryStatusKey } from "../../common/locale";
import { getUsableWidgetQueryData } from "../../common/query-state";
dayjs.extend(objectSupport);
dayjs.extend(relativeTime);
@@ -20,17 +22,23 @@ export default function NetworkControllerSummaryWidget({
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const statusLabels = {
enabled: t("widget.dnsHoleSummary.status.enabled"),
disabled: t("widget.dnsHoleSummary.status.disabled"),
unknown: t("widget.dnsHoleSummary.status.unknown"),
};
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && height < 160;
const columns = isAdvanced && width >= 720 ? 2 : 1;
const isDense = height < 160;
const showDetails = height >= 180 && width >= 240;
const columns = width >= 720 ? 2 : 1;
if (isPending || summaries.length === 0) {
return (
@@ -43,12 +51,12 @@ export default function NetworkControllerSummaryWidget({
}
return (
<ScrollArea h="100%" p={isAdvanced ? "md" : isDense ? "xs" : "sm"}>
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
<SimpleGrid cols={columns} spacing="sm">
{summaries.map(({ integration, summary }) => (
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
<Stack gap="xs">
{(isAdvanced || summaries.length > 1) && (
{summaries.length > 1 && (
<Group justify="space-between" wrap="nowrap">
<Text fw={600} size="sm" truncate="end">
{integration.name}
@@ -59,24 +67,66 @@ export default function NetworkControllerSummaryWidget({
</Group>
)}
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
<List.Item icon={<StatusIcon status={summary.wanStatus} size={isDense ? 16 : 20} />}>WAN</List.Item>
<List.Item icon={<StatusIcon status={summary.www.status} size={isDense ? 16 : 20} />}>
<List.Item
icon={
<StatusIcon
status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
size={isDense ? 16 : 20}
/>
}
>
WAN
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.www.status}
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
size={isDense ? 16 : 20}
/>
}
>
<Text>
WWW
<Text c="dimmed" size="sm" ms="xs" span>
{summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
</Text>
</Text>
</List.Item>
<List.Item icon={<StatusIcon status={summary.wifi.status} size={isDense ? 16 : 20} />}>
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
<List.Item
icon={
<StatusIcon
status={summary.wifi.status}
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
size={isDense ? 16 : 20}
/>
}
>
Wi-Fi{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{isAdvanced && (
<List.Item icon={<StatusIcon status={summary.lan.status} size={20} />}>
{showDetails && (
<List.Item
icon={
<StatusIcon
status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
size={20}
/>
}
>
LAN · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item icon={<StatusIcon status={summary.vpn.status} size={isDense ? 16 : 20} />}>
<List.Item
icon={
<StatusIcon
status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
size={isDense ? 16 : 20}
/>
}
>
<Text>
VPN
<Text c="dimmed" size="sm" ms="xs" span>
@@ -93,13 +143,13 @@ export default function NetworkControllerSummaryWidget({
);
}
const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => {
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
const mantineTheme = useMantineTheme();
if (status === "enabled") {
return <IconCircleCheckFilled aria-label={status} size={size} color={mantineTheme.colors.green[6]} />;
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
}
if (status === "disabled") {
return <IconCircleXFilled aria-label={status} size={size} color={mantineTheme.colors.red[6]} />;
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.red[6]} />;
}
return <IconCircleXFilled aria-label="unknown" size={size} color={mantineTheme.colors.gray[6]} />;
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.gray[6]} />;
};
@@ -6,6 +6,7 @@ import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
supportsAdvancedFocus: false,
icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
supportsAdvancedFocus: false,
icon: IconNotes,
createOptions() {
return optionsBuilder.from(
+14 -12
View File
@@ -97,7 +97,6 @@ export function Notebook({
isEditMode,
boardId,
itemId,
displayMode,
height,
}: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
@@ -146,8 +145,8 @@ export function Notebook({
previousContentRef.current = contentUpdate;
setOptions({ newOptions: { content: contentUpdate } });
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
} catch {
setSaveError(t("widget.notebook.saveFailed"));
return false;
} finally {
savingRef.current = false;
@@ -366,7 +365,7 @@ export function Notebook({
},
content: {
backgroundColor: "transparent",
padding: height < 120 && displayMode !== "advanced" ? "0.25rem" : "0.5rem",
padding: height < 120 ? "0.25rem" : "0.5rem",
height: "100%",
},
typographyStylesProvider: {
@@ -376,8 +375,9 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display:
isEditing && !isSaving && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
maxHeight: height < 180 ? "4.5rem" : "10rem",
overflowY: "auto",
}}
>
<RichTextEditor.ControlsGroup>
@@ -505,8 +505,8 @@ export function Notebook({
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{displayMode === "advanced" && (
<Group pos="absolute" bottom={4} right={8} gap="xs">
{(saveError || height >= 180) && (
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
{saveError && (
<Tooltip label={saveError} multiline>
<Text
@@ -521,16 +521,18 @@ export function Notebook({
</Text>
</Tooltip>
)}
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
{height >= 180 && (
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
)}
</Group>
)}
{canChange && (
<Stack pos="absolute" top={7} right={7} gap={7} style={{ zIndex: 1 }}>
<ActionIcon
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible={isEditing || displayMode === "advanced" || undefined}
data-visible={isEditing || undefined}
title={isEditing ? t("common.action.save") : t("common.action.edit")}
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
color={primaryColor}
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -10,18 +10,21 @@ import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
export default function NotificationsWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [], isPending } = clientApi.widget.notifications.getNotifications.useQuery({
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? [];
const { isPending } = notificationsQuery;
const t = useI18n();
@@ -43,22 +46,21 @@ export default function NotificationsWidget({
const failedIntegrations = notificationIntegrations.filter(
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
);
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 280 || height < 180);
const bodyLineClamp = isAdvanced ? 12 : height < 112 ? 1 : isDense ? 2 : 4;
const columns = isAdvanced && width >= 720 ? 2 : 1;
const isDense = width < 280 || height < 180;
const isRoomy = width >= 360 && height >= 220;
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
const columns = width >= 720 ? 2 : 1;
const spacing = isRoomy ? "sm" : "xs";
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
<Tooltip key={integration.integration.id} label={integration.error} multiline maw={360}>
<Badge color="red" variant="light" size="xs">
{integration.integration.name}: {t("common.error")}
</Badge>
</Tooltip>
<Badge key={integration.integration.id} color="red" variant="light" size="xs">
{integration.integration.name}: {t("common.error")}
</Badge>
))}
</Group>
)}
@@ -69,60 +71,64 @@ export default function NotificationsWidget({
</Text>
</Flex>
) : sortedNotifications.length > 0 ? (
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
{sortedNotifications.map((notification) => (
<Card
key={notification.compositeKey}
component={notification.href ? "a" : "div"}
href={notification.href}
target={notification.href ? "_blank" : undefined}
rel={notification.href ? "noopener noreferrer" : undefined}
radius={board.itemRadius}
w="100%"
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
bg={isAdvanced ? undefined : "transparent"}
style={{
color: "inherit",
textDecoration: "none",
borderBottom: isAdvanced
? undefined
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size={isDense ? "xs" : "sm"}
radius={board.itemRadius}
/>
)}
<Flex gap={isAdvanced ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
{notification.title && (
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
{notification.title}
</Text>
<SimpleGrid cols={columns} spacing={spacing} verticalSpacing={spacing}>
{sortedNotifications.map((notification) => {
const href = getSafeApplicationUrl(notification.href);
return (
<Card
key={notification.compositeKey}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
radius={board.itemRadius}
w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
bg={columns > 1 ? undefined : "transparent"}
style={{
color: "inherit",
textDecoration: "none",
borderBottom:
columns > 1
? undefined
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size={isDense ? "xs" : "sm"}
radius={board.itemRadius}
/>
)}
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
lineClamp={bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
{notification.body}
</Text>
<InfoDisplay
date={notification.time}
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
dense={isDense}
/>
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
{notification.title && (
<Text fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
{notification.title}
</Text>
)}
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
lineClamp={bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
{notification.body}
</Text>
<InfoDisplay
date={notification.time}
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
dense={isDense}
/>
</Flex>
</Flex>
</Flex>
</Card>
))}
</Card>
);
})}
</SimpleGrid>
) : (
<Flex justify="center" align="center" mih={96} p="sm">
@@ -75,7 +75,6 @@ export default function PaperlessNgxWidget({
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const tCommon = useScopedI18n("common");
@@ -116,16 +115,15 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const advanced = displayMode === "advanced";
const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
const ringSize = advanced ? 112 : getRingSize(width);
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height)));
const ringSize = getRingSize(Math.min(width, height * 2));
const iconSize = getIconSize(width);
const ringLabelSize = getRingLabelSize(ringSize);
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const showInboxRing = options.showInboxRing && (advanced || height >= 120);
const showInboxRing = options.showInboxRing && height >= 120;
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
@@ -163,11 +161,7 @@ export default function PaperlessNgxWidget({
} as const;
return (
<div
className={classes.root}
data-display-mode={displayMode}
style={advanced ? { padding: 16, gap: 16 } : undefined}
>
<div className={classes.root}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
@@ -210,9 +204,9 @@ function computeInboxProgress(total: number, inbox: number): number {
return Math.round((inbox / total) * 100);
}
function getGridCols(width: number): number {
function getGridCols(width: number, height: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5);
}
function getRingSize(width: number): number {
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
supportsAdvancedFocus: false,
icon: IconFileText,
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
+15 -19
View File
@@ -75,17 +75,15 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
return "comfortable";
};
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"patchmon">) {
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery(
{ integrationId },
{ staleTime: 60 * 1000 },
);
if (error && !stats) throw error;
if (!stats) return <WidgetEmptyState />;
const statValues: Record<PatchMonStatKey, number> = {
@@ -104,20 +102,18 @@ export default function PatchMonWidget({
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
const advanced = displayMode === "advanced";
const layout = advanced ? "spacious" : getLayoutMode(width, height);
const isLandscape = !advanced && width / height > 2.5;
const layout = getLayoutMode(width, height);
const isLandscape = width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
const isCompactSurface = !advanced && (isTinySquare || isShortLandscape);
const isCompactSurface = isTinySquare || isShortLandscape;
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
const showHero =
options.showComplianceHero && (advanced || !isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
@@ -128,10 +124,10 @@ export default function PatchMonWidget({
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && (advanced || height > 200);
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = advanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
const showOsLegend = osDisplayMode === "donut" && (advanced || (width >= 380 && height >= 300));
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
@@ -280,10 +276,10 @@ export default function PatchMonWidget({
</div>
);
const needsScroll = !advanced && (layout === "mini" || layout === "compact");
const needsScroll = layout === "mini" || layout === "compact";
return (
<div className={classes.root} data-display-mode={displayMode}>
<div className={classes.root}>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
+1
View File
@@ -7,6 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: false,
icon: IconShieldCheck,
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
+15 -15
View File
@@ -38,6 +38,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import classes from "./component.module.scss";
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
import { getReleasesQueryStaleTimeMs } from "./query-options";
@@ -48,7 +49,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -59,7 +60,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
key: "releases-viewed-versions",
defaultValue: {},
});
const isAdvanced = displayMode === "advanced";
const columns = Math.max(1, Math.floor(width / 420));
const relativeDateOptions = useMemo(
() => ({
@@ -187,11 +188,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
return (
<ScrollArea h="100%" className="releases">
<SimpleGrid
cols={isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1}
spacing={isAdvanced ? "sm" : 0}
p={isAdvanced ? "xs" : 0}
>
<SimpleGrid cols={columns} spacing={columns > 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}>
{repositories.map((repository: ReleasesRepositoryResponse) => {
const isActive = expandedRepositoryId === repository.id;
const hasError = repository.error !== undefined;
@@ -207,7 +204,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
)}
gap={0}
style={
isAdvanced
columns > 1
? {
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-sm)",
@@ -324,7 +321,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
</Group>
</Group>
</UnstyledButton>
{(options.showDetails || isAdvanced) && (
{options.showDetails && (
<DetailsDisplay
repository={repository}
isExpanded={isActive}
@@ -582,6 +579,8 @@ const ExpandedDisplay = ({
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
const releaseUrl = getSafeApplicationUrl(repository.releaseUrl);
const repositoryUrl = releaseUrl ?? getSafeApplicationUrl(repository.projectUrl);
return (
<>
@@ -651,19 +650,19 @@ const ExpandedDisplay = ({
</Group>
</Button>
{(repository.releaseUrl ?? repository.projectUrl) && (
{repositoryUrl && (
<Button
className="releases-repository-expanded-openButton"
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
href={repositoryUrl}
target="_blank"
rel="noreferrer"
rel={SAFE_NEW_TAB_REL}
>
<Group className="releases-repository-expanded-openButton-wrapper" gap={5} justify="center" align="center">
<IconExternalLink className="releases-repository-expanded-openButton-icon" size="1.5em" />
<Text className="releases-repository-expanded-openButton-text">
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
{releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Text>
</Group>
</Button>
@@ -682,8 +681,9 @@ const ExpandedDisplay = ({
c="red"
style={{ whiteSpace: "pre-wrap" }}
>
{repository.error.message ??
(repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)}
{repository.error.code
? t(`error.messages.${repository.error.code}` as never)
: t("error.messages.unexpected")}
</Text>
</>
)}
+1
View File
@@ -13,6 +13,7 @@ const relativeDateSchema = z
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
supportsAdvancedFocus: false,
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
+55 -73
View File
@@ -10,6 +10,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import classes from "./component.module.scss";
const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
@@ -33,7 +34,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
};
};
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) {
const {
entries: feedEntries,
failedFeedCount,
@@ -53,64 +54,68 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
const languageDir = options.enableRtl ? "RTL" : "LTR";
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 420 || height < 180);
const isTiny = !isAdvanced && (width < 260 || height < 110);
const columns = isAdvanced && width >= 720 ? 2 : 1;
const descriptionLines = isAdvanced ? Math.max(options.textLinesClamp, 8) : isDense ? 1 : options.textLinesClamp;
const isDense = width < 420 || height < 180;
const isTiny = width < 260 || height < 110;
const isRoomy = width >= 420 && height >= 240;
const columns = width >= 720 && height >= 260 ? 2 : 1;
const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp;
const spacing = isRoomy ? "sm" : "xs";
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : isTiny ? 4 : "xs"}>
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
{warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
<output>{warning}</output>
</Alert>
)}
<SimpleGrid cols={columns} w="100%" spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
{feedEntries.map((feedEntry) => (
<Card
key={feedEntry.id}
className={classes.entry}
component={"a"}
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl)}
radius={board.itemRadius}
target="_blank"
rel="noopener noreferrer"
w="100%"
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
alt=""
w={isAdvanced ? 180 : isDense ? 64 : 96}
h={isAdvanced ? 120 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
)}
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{feedDescriptionToText(feedEntry.description)}
</Text>
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
{feedEntries.map((feedEntry) => {
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
return (
<Card
key={feedEntry.id}
className={classes.entry}
component={href ? "a" : "div"}
href={href}
radius={board.itemRadius}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
w="100%"
p={isDense ? 6 : "xs"}
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
alt=""
w={isRoomy ? 140 : isDense ? 64 : 96}
h={isRoomy ? 96 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
)}
<InfoDisplay
source={isAdvanced || !isDense ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
/>
</Flex>
</Group>
</Card>
))}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={!isDense ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
/>
</Flex>
</Group>
</Card>
);
})}
</SimpleGrid>
</ScrollArea>
);
@@ -134,29 +139,6 @@ export const getHostname = (url: string): string => {
}
};
export const getSafeExternalUrl = (value: unknown, baseUrl?: string): string | undefined => {
if (typeof value !== "string") return undefined;
try {
let sanitizedBaseUrl: string | undefined;
if (baseUrl) {
const parsedBaseUrl = new URL(baseUrl);
parsedBaseUrl.username = "";
parsedBaseUrl.password = "";
parsedBaseUrl.search = "";
parsedBaseUrl.hash = "";
sanitizedBaseUrl = parsedBaseUrl.toString();
}
const url = new URL(value, sanitizedBaseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
url.username = "";
url.password = "";
return url.toString();
} catch {
return undefined;
}
};
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
<Group gap={5} align="center" wrap="nowrap">
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { feedDescriptionToText, getHostname, getSafeExternalUrl } from "./component";
import { feedDescriptionToText, getHostname } from "./component";
describe("RSS feed display helpers", () => {
test("renders feed markup as inert text", () => {
@@ -13,22 +13,4 @@ describe("RSS feed display helpers", () => {
expect(getHostname("not a URL")).toBe("not a URL");
expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
});
test("only exposes credential-free HTTP links from untrusted feed entries", () => {
expect(getSafeExternalUrl("https://example.com/story")).toBe("https://example.com/story");
expect(getSafeExternalUrl("/story", "https://example.com/feed.xml")).toBe("https://example.com/story");
expect(getSafeExternalUrl("/story", "https://user:password@example.com/feed.xml")).toBe(
"https://example.com/story",
);
expect(getSafeExternalUrl("https://user:password@example.com/story")).toBe("https://example.com/story");
expect(getSafeExternalUrl(undefined, "https://example.com/feed?token=secret")).toBeUndefined();
expect(getSafeExternalUrl("#story", "https://example.com/feed?token=secret#old")).toBe(
"https://example.com/feed#story",
);
expect(getSafeExternalUrl("?story=1", "https://example.com/feed?token=secret")).toBe(
"https://example.com/feed?story=1",
);
expect(getSafeExternalUrl("javascript:alert(1)")).toBeUndefined();
expect(getSafeExternalUrl("not a URL")).toBeUndefined();
});
});
+1
View File
@@ -11,6 +11,7 @@ import { optionsBuilder } from "../options";
* - https://www.jsonfeed.org/version/1.1/
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
supportsAdvancedFocus: false,
icon: IconRss,
queryKey: [["widget", "rssFeed", "getFeeds"]],
queryMatcher: ({ input }, scope) =>
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import type { QueryKey } from "@tanstack/react-query";
import type { WidgetRuntimeActions, WidgetRuntimeRef } from "./definition";
import { normalizeWidgetQuery } from "./definition";
export const useWidgetRuntimeQueries = (
widgetRuntimeRef: WidgetRuntimeRef | undefined,
queryKeys: readonly QueryKey[],
) => {
useEffect(() => {
if (!widgetRuntimeRef) return;
const runtime = widgetRuntimeRef.current;
const queries = queryKeys.flatMap((queryKey) => {
const query = normalizeWidgetQuery(queryKey);
return query ? [query] : [];
});
runtime.queries = queries;
return () => {
if (runtime.queries === queries) runtime.queries = [];
};
}, [queryKeys, widgetRuntimeRef]);
};
export const useWidgetRuntimeActions = (
widgetRuntimeRef: WidgetRuntimeRef | undefined,
actions: WidgetRuntimeActions,
) => {
useEffect(() => {
if (!widgetRuntimeRef) return;
const runtime = widgetRuntimeRef.current;
runtime.actions = actions;
return () => {
if (runtime.actions === actions) runtime.actions = {};
};
}, [actions, widgetRuntimeRef]);
};
@@ -9,7 +9,6 @@ import {
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
@@ -106,7 +105,7 @@ export default function SmartHomeTriggerAutomationWidget({
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
pointerEvents: isEditMode ? "none" : undefined,
}}
aria-description={error?.message}
aria-description={error ? t("widget.smartHome-executeAutomation.error.executeFailed") : undefined}
w="100%"
h="100%"
>
@@ -123,11 +122,9 @@ export default function SmartHomeTriggerAutomationWidget({
{error && displayMode !== "advanced" && (
<Overlay>
<Center w="100%" h="100%" p="xs">
<Tooltip label={error.message} multiline>
<Text size="xs" c="red" ta="center" lineClamp={3}>
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Tooltip>
<Text size="xs" c="red" ta="center" lineClamp={3}>
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Center>
</Overlay>
)}
@@ -142,11 +139,9 @@ export default function SmartHomeTriggerAutomationWidget({
{displayMode === "advanced" && (
<>
{error ? (
<Tooltip label={error.message} multiline>
<Text ta="center" size="xs" c="red">
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Tooltip>
<Text ta="center" size="xs" c="red">
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
) : (
<Text ta="center" size="xs" c="dimmed">
{lastExecutedAt
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
@@ -21,7 +22,8 @@ export default function SpeedtestTrackerWidget({
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData =
getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? [];
const [advancedRange, setAdvancedRange] = useState("24");
const combined = useMemo(
@@ -19,7 +19,7 @@ describe("stock summary", () => {
describe("stock layout", () => {
it("keeps compact widgets focused on symbol and price", () => {
expect(getStockLayout(320, 100, false)).toMatchObject({
expect(getStockLayout(320, 100)).toMatchObject({
showName: false,
showChange: false,
showRange: false,
@@ -28,12 +28,12 @@ describe("stock layout", () => {
});
it("reveals secondary data only when both axes have room", () => {
expect(getStockLayout(320, 160, false)).toMatchObject({
expect(getStockLayout(320, 160)).toMatchObject({
showName: false,
showChange: true,
showRange: true,
});
expect(getStockLayout(320, 220, false)).toMatchObject({
expect(getStockLayout(320, 220)).toMatchObject({
showName: true,
showChange: true,
showRange: true,
@@ -41,8 +41,8 @@ describe("stock layout", () => {
});
});
it("always exposes the complete advanced summary", () => {
expect(getStockLayout(120, 80, true)).toEqual({
it("exposes the complete summary when the widget is roomy", () => {
expect(getStockLayout(640, 360)).toEqual({
showName: true,
showChange: true,
showRange: true,
+6 -15
View File
@@ -38,22 +38,12 @@ export interface StockLayout {
priceOrder: 1 | 2;
}
export function getStockLayout(width: number, height: number, isAdvanced: boolean): StockLayout {
if (isAdvanced) {
return {
showName: true,
showChange: true,
showRange: true,
graphHeight: "75%",
priceOrder: 1,
};
}
export function getStockLayout(width: number, height: number): StockLayout {
return {
showName: width >= 300 && height >= 190,
showChange: width >= 260 && height >= 130,
showRange: width >= 220 && height >= 130,
graphHeight: height >= 220 ? "68%" : "48%",
graphHeight: height >= 320 ? "75%" : height >= 220 ? "68%" : "48%",
priceOrder: width >= 280 && height >= 120 ? 1 : 2,
};
}
@@ -71,13 +61,14 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
};
}
export default function StockPriceWidget({ options, width, height, displayMode }: WidgetComponentProps<"stockPrice">) {
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
const t = useScopedI18n("widget.stockPrice");
const locale = useCurrentIntlLocale();
const numberFormatter = new Intl.NumberFormat(locale);
const theme = useMantineTheme();
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
const { data: result, error } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
if (error && !result) throw error;
if (!result) return <WidgetEmptyState />;
const { data } = result;
@@ -87,7 +78,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
const stockGraphValues = summary.graphValues;
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
const layout = getStockLayout(width, height, displayMode === "advanced");
const layout = getStockLayout(width, height);
return (
<Flex h="100%" w="100%">
+1
View File
@@ -12,6 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range;
const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
supportsAdvancedFocus: false,
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
queryMatcher: ({ input }, scope) =>
+13 -11
View File
@@ -9,6 +9,7 @@ import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -57,7 +58,7 @@ interface SystemDiskCardProps {
showBackgroundBar: boolean;
integrationName?: string;
secondaryText?: string;
isAdvanced: boolean;
showSecondaryText: boolean;
showTemperature: boolean;
}
@@ -70,7 +71,7 @@ const SystemDiskCard = ({
showBackgroundBar,
integrationName,
secondaryText,
isAdvanced,
showSecondaryText,
showTemperature,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
@@ -98,8 +99,10 @@ const SystemDiskCard = ({
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
const tooltipLabel = [
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
hasHiddenSecondaryText ? secondaryText : null,
hasHiddenTemperature ? `${temperature}°C` : null,
]
.filter(Boolean)
@@ -108,7 +111,7 @@ const SystemDiskCard = ({
return (
<Tooltip
label={tooltipLabel}
disabled={(valueFits && !hasHiddenTemperature) || isAdvanced}
disabled={valueFits && !hasHiddenTemperature && !hasHiddenSecondaryText}
position="top"
withinPortal
>
@@ -132,7 +135,7 @@ const SystemDiskCard = ({
<span>{displayText}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</Text>
{isAdvanced && secondaryText && (
{showSecondaryText && secondaryText && secondaryText !== displayText && (
<Text size="xs" c="dimmed">
{secondaryText}
</Text>
@@ -166,10 +169,10 @@ export default function SystemResources({
options,
width,
height,
displayMode: surfaceMode,
}: WidgetComponentProps<"systemDisks">) {
const queryInput = { integrationIds };
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
const data =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
if (data.length === 0) return <WidgetEmptyState />;
@@ -193,8 +196,7 @@ export default function SystemResources({
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
if (disks.length === 0) return <WidgetEmptyState />;
const isAdvanced = surfaceMode === "advanced";
const minimumCardWidth = isAdvanced ? 320 : 260;
const minimumCardWidth = 260;
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
const cellWidth = width / columns;
@@ -210,10 +212,10 @@ export default function SystemResources({
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
showBackgroundBar={options.showBackgroundBar}
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
integrationName={data.length > 1 ? integrationName : undefined}
secondaryText={getAbsoluteText(item)}
isAdvanced={isAdvanced}
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
showSecondaryText={height >= 160 && cellWidth >= 260}
showTemperature={height >= 100 && cellWidth >= 220}
/>
))}
</SimpleGrid>
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
supportsAdvancedFocus: false,
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
+9 -5
View File
@@ -1,11 +1,11 @@
import { Alert, Badge, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import { formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({
@@ -55,11 +55,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
});
const t = useScopedI18n("widget.timetable");
const tCommon = useScopedI18n("common");
const locale = useCurrentIntlLocale();
if (error && timetable === undefined) throw error;
const entries = timetable?.entries ?? [];
const staleTime = timetable ? dayjs(timetable.timestamp).format("HH:mm:ss") : undefined;
const staleTime = timetable ? formatLocalizedTime(timetable.timestamp, locale, { includeSeconds: true }) : undefined;
const staleWarning = error && staleTime ? t("warning.stale", { time: staleTime }) : undefined;
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
const isAdvanced = displayMode === "advanced";
@@ -104,6 +105,7 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
dense={isDense}
showLine={showLine}
showPlatform={showPlatform}
locale={locale}
/>
))
) : (
@@ -117,7 +119,7 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
</ScrollArea>
{displayMode === "advanced" && timetable && (
<Text size="xs" c="dimmed" ta="right">
{dayjs(timetable.timestamp).format("HH:mm:ss")}
{formatLocalizedTime(timetable.timestamp, locale, { includeSeconds: true })}
</Text>
)}
</Stack>
@@ -137,11 +139,13 @@ function DepartureRow({
dense,
showLine,
showPlatform,
locale,
}: {
entry: TimetableEntryView;
dense: boolean;
showLine: boolean;
showPlatform: boolean;
locale: string;
}) {
return (
<Group justify="space-between" w="100%" wrap="nowrap">
@@ -159,7 +163,7 @@ function DepartureRow({
</Badge>
)}
<Text size={dense ? "xs" : "sm"} fw={dense ? 600 : undefined} style={{ whiteSpace: "nowrap" }}>
{dayjs(entry.timestamp).format("HH:mm")}{" "}
{formatLocalizedTime(entry.timestamp, locale)}{" "}
{entry.delay >= 1 && (
<Text size={dense ? "xs" : "sm"} span c="red">
+{entry.delay}&apos;
+3 -1
View File
@@ -7,6 +7,7 @@ import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import { formatTotalTracearrBitrate } from "./bitrate";
import { RecentActivityList } from "./recent-activity-section";
@@ -67,7 +68,8 @@ interface TracearrContentProps {
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
const t = useScopedI18n("widget.tracearr");
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
const dashboardData =
getUsableWidgetQueryData(clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds })) ?? [];
// Merge data from all integrations
const combinedData = dashboardData.reduce<TracearrDashboardData>(
+2 -1
View File
@@ -17,6 +17,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -65,7 +66,7 @@ function TraefikWidgetContent({
displayMode = "compact",
}: WidgetComponentProps<"traefik">) {
const t = useScopedI18n("widget.traefik");
const { data } = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
const data = getUsableWidgetQueryData(clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }));
if (!data) return <WidgetEmptyState />;
+25 -28
View File
@@ -1,7 +1,7 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
@@ -12,6 +12,8 @@ import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
interface UmamiContentProps {
integrationIds: string[];
@@ -43,46 +45,31 @@ export function UmamiContent({
displayMode,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const tCommon = useScopedI18n("common");
const locale = useCurrentIntlLocale();
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const tickColor = "var(--mantine-color-dimmed)";
const {
data: results,
isPending: isStatsPending,
error: statsError,
} = clientApi.widget.umami.getVisitorStats.useQuery(
const statsQuery = clientApi.widget.umami.getVisitorStats.useQuery(
{ integrationIds, websiteId, timeFrame, eventName },
umamiQueryOptions,
);
const results = getUsableWidgetQueryData(statsQuery);
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(
activeVisitorsInput,
umamiQueryOptions,
);
const activeVisitorsQuery = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput, umamiQueryOptions);
const activeVisitors = activeVisitorsQuery.data;
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
const multiEventQuery = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventSeries = multiEventQuery.data;
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
: undefined;
const firstResult = results?.[0];
if (isStatsPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (statsError && results === undefined) throw statsError;
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
if (!firstResult) {
return (
<Stack align="center" justify="center" h="100%">
@@ -212,7 +199,7 @@ export function UmamiContent({
);
return (
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%">
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%" pos="relative">
<Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
@@ -255,9 +242,11 @@ export function UmamiContent({
</Group>
{showSecondaryStats && (
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString(locale)} {t("active")}
</Text>
{activeVisitors !== undefined && (
<Text size="xs" c="green">
● {activeVisitors.toLocaleString(locale)} {t("active")}
</Text>
)}
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString(locale)} {t("pageviews")}
</Text>
@@ -279,6 +268,14 @@ export function UmamiContent({
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? activeVisitorsQuery.error ?? multiEventQuery.error}
label={t("name")}
/>
</Box>
)}
</Stack>
);
}
@@ -1,13 +1,14 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
interface UmamiEventsContentProps {
integrationIds: string[];
@@ -28,13 +29,15 @@ export function UmamiEventsContent({
}: UmamiEventsContentProps) {
const t = useScopedI18n("widget.umami");
const locale = useCurrentIntlLocale();
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const tickColor = "var(--mantine-color-dimmed)";
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
);
const series =
getUsableWidgetQueryData(
clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
),
) ?? [];
if (eventNames.length === 0) {
return (
+15 -8
View File
@@ -7,6 +7,7 @@ import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { umamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
interface UmamiTopListProps {
integrationIds: string[];
@@ -17,19 +18,25 @@ interface UmamiTopListProps {
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const { data = [] } = clientApi.widget.umami.getTopPages.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
);
const data =
getUsableWidgetQueryData(
clientApi.widget.umami.getTopPages.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
),
) ?? [];
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
}
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
);
const data =
getUsableWidgetQueryData(
clientApi.widget.umami.getTopReferrers.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
),
) ?? [];
return (
<UmamiTopList
items={data}
+2 -1
View File
@@ -22,6 +22,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
const statusColors: Record<UpsStatus, string> = {
@@ -66,7 +67,7 @@ interface UpsContentProps {
function UpsContent({ integrationIds, options, width, height, displayMode }: UpsContentProps) {
const t = useScopedI18n("widget.ups");
const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds });
const data = getUsableWidgetQueryData(clientApi.widget.ups.getSummaries.useQuery({ integrationIds }));
if (!data) return <WidgetEmptyState />;
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more