fix(widgets): restore compact v1 surfaces

This commit is contained in:
ajnart committed 2026-08-27 09:18:10 +02:00
1 parent 1b2f1bd720
commit 3edf874ca3
102 files changed
+1513 -1205

No files matched your search

+6 -6
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
<Text
className="app-title"
fw={700}
size={isTiny ? "xs" : "sm"}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && !isTiny && (
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size="sm"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
lineClamp={4}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
</Flex>
@@ -9,14 +9,7 @@ import { zoomCompensatedSize } from "@homarr/ui";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import {
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
@@ -33,13 +26,20 @@ const statTileClassByCompact = {
false: "",
} as const;
const statTileClassByAdvanced = {
true: classes.statTileAdvanced,
false: "",
} as const;
const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220;
interface AudioStatsContentProps {
backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
showAllStats?: boolean;
advanced?: boolean;
width: number;
height: number;
}
export function AudioStatsContent({
@@ -47,15 +47,18 @@ export function AudioStatsContent({
stats,
options,
showAllStats = false,
advanced = false,
width,
height,
}: AudioStatsContentProps) {
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, compact);
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
let gridCols = getGridCols(width, visibleStats.length, compact);
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
gridCols = 1;
}
const iconSize = getIconSize(width, compact);
return (
@@ -66,7 +69,10 @@ export function AudioStatsContent({
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<div
key={optionKey}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]}`}
>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
@@ -40,10 +40,14 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileAdvanced {
background-color: transparent;
}
.statTileCompact {
gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px);
@@ -57,7 +61,11 @@
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 11px);
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(11px * var(--board-canvas-ui-scale, 1))
);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -68,11 +76,19 @@
}
.statTileCompact .statLabel {
font-size: clamp(7px, 2cqi, 9px);
font-size: clamp(
calc(7px * var(--board-canvas-ui-scale, 1)),
calc(2cqi * var(--board-canvas-ui-scale, 1)),
calc(9px * var(--board-canvas-ui-scale, 1))
);
}
.statValue {
font-size: clamp(13px, 5cqi, 20px);
font-size: clamp(
calc(13px * var(--board-canvas-ui-scale, 1)),
calc(5cqi * var(--board-canvas-ui-scale, 1)),
calc(20px * var(--board-canvas-ui-scale, 1))
);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
@@ -84,7 +100,11 @@
}
.statTileCompact .statValue {
font-size: clamp(11px, 4cqi, 16px);
font-size: clamp(
calc(11px * var(--board-canvas-ui-scale, 1)),
calc(4cqi * var(--board-canvas-ui-scale, 1)),
calc(16px * var(--board-canvas-ui-scale, 1))
);
}
.emptyState {
@@ -17,7 +17,7 @@ export default function AudioStatsWidget({
integrationIds,
options,
width,
height,
displayScale = 1,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
@@ -39,14 +39,19 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
}
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
showAllStats={displayMode === "advanced"}
width={width}
height={height}
advanced={displayMode === "advanced"}
width={responsiveWidth}
/>
);
if (displayMode === "compact") {
@@ -1,20 +1,7 @@
"use client";
import { useMemo } from "react";
import {
Badge,
Box,
Center,
Divider,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
@@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
height,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
@@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
const showHistory = isAdvanced || (options.showHistory && height >= 360);
const showAlertDescriptions = isAdvanced || height >= 190;
const showHistory = isAdvanced || options.showHistory;
if (isPending) {
return (
@@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} c="red">
<Text size="xs" fw={600} c="red" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
@@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
showDescription={showAlertDescriptions}
isAdvanced={isAdvanced}
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{showOkAlerts && okAlerts.length > 0 && (
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
@@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
showDescription={showAlertDescriptions}
isAdvanced={isAdvanced}
/>
))}
</Stack>
@@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("history")}
</Text>
</Group>
@@ -250,7 +234,7 @@ interface AlertRowProps {
systemName: string;
integrationName?: string;
triggered: boolean;
showDescription: boolean;
isAdvanced: boolean;
}
const unitSuffixMap: Record<string, string> = {
@@ -274,43 +258,41 @@ function formatAlertDescription(name: string, value: number, min: number): strin
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
const description = formatAlertDescription(name, value, min);
const accentColor = triggered ? "red" : "green";
const borderColor = triggered ? "red" : "gray";
const rowStyle = isAdvanced
? {
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${borderColor}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}
: {
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
};
return (
<Tooltip label={description} disabled={showDescription} withArrow>
<Group
wrap="nowrap"
gap="xs"
py={showDescription ? 4 : 2}
px={8}
style={{
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
{showDescription && (
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
)}
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
</Stack>
</Group>
</Tooltip>
<Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}>
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
</Stack>
</Group>
);
}
+2 -8
View File
@@ -17,7 +17,6 @@ export default function CoolifyWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useI18n("widget.coolify");
@@ -38,7 +37,6 @@ export default function CoolifyWidget({
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
@@ -48,11 +46,10 @@ interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
@@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
);
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
const isTiny = width < 256 || height < 144;
const hideFooter = height < 112;
const isTiny = width < 256;
const [firstInstance] = successfulInstances;
const widgetKey = createWidgetKey(integrationIds);
@@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
</div>
</Stack>
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
))}
</SimpleGrid>
+7 -38
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
@@ -22,12 +22,10 @@ interface InstanceCardProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) {
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running",
).length;
const healthyResources =
(options.showServers ? onlineServers : 0) +
(options.showApplications ? runningApps : 0) +
(options.showServices ? runningServices : 0);
const totalResources =
(options.showServers ? instance.instanceInfo.servers.length : 0) +
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
(options.showServices ? instance.instanceInfo.services.length : 0);
const resourceSummary = [
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
options.showApplications
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
: null,
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
]
.filter(Boolean)
.join(" · ");
return (
<Card p={0} radius="sm" bg="transparent">
<Group
p="xs"
justify="space-between"
wrap="nowrap"
className={classes.neutralDividerBottom}
>
<Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}>
<Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Stack gap={0} miw={0}>
@@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? (
<Tooltip label={resourceSummary}>
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
{healthyResources}/{totalResources}
</Badge>
</Tooltip>
) : null}
{!isTiny && options.showServers && (
{options.showServers && (
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
{onlineServers}/{instance.instanceInfo.servers.length}
</Badge>
)}
{!isTiny && options.showApplications && (
{options.showApplications && (
<Badge
variant="dot"
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
@@ -123,7 +92,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
{runningApps}/{instance.instanceInfo.applications.length}
</Badge>
)}
{!isTiny && options.showServices && (
{options.showServices && (
<Badge
variant="dot"
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
@@ -168,7 +137,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
)}
</Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Card>
);
}
@@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconLink size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
{!isTiny && logsUrl && (
{logsUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
@@ -129,12 +129,10 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconFileText size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
{!isTiny && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
)}
{!isTiny && resourceTimestamp && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
{resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto">
{resourceTimestamp}
</Text>
@@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function SingleInstanceLayout({
instance,
options,
isTiny,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -115,7 +107,7 @@ export function SingleInstanceLayout({
)}
</Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Stack>
</ScrollArea>
);
+1
View File
@@ -180,6 +180,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
@@ -57,7 +57,7 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -236,7 +236,7 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
@@ -275,7 +275,7 @@ interface ControlsCardProps {
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
@@ -288,7 +288,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t,
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
@@ -299,7 +299,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md";
return (
<Indicator
+3 -29
View File
@@ -16,7 +16,6 @@ import {
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
@@ -111,33 +110,11 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useI18n("docker.field.state.option");
const label = t(state);
return (
<Tooltip label={label} withArrow>
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
<Box
component="span"
aria-hidden="true"
w={8}
h={8}
bg={`${containerStateColorMap[state]}.6`}
style={{ borderRadius: "50%" }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</Box>
</Tooltip>
);
};
const createColumns = (
t: ReturnType<typeof useI18n<"docker">>,
tCommon: ReturnType<typeof useI18n<"common">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
@@ -147,7 +124,6 @@ const createColumns = (
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
@@ -387,14 +363,12 @@ export default function DockerWidget({
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width],
);
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, tCommon, actionHandlers, sortingEnabled, inlineState).filter(
return createColumns(t, tCommon, actionHandlers, sortingEnabled).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
}, [actionHandlers, columnVisibility, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -435,7 +409,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding="2px 8px"
cellPadding={isAdvanced || width < 400 ? "2px 8px" : "4px 8px"}
className="docker-table"
rowCursor="default"
fetching={isFetching && containers.length === 0}
+23 -16
View File
@@ -5,27 +5,34 @@ import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
test("keeps every configured column in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: false,
state: true,
host: true,
cpuUsage: true,
memoryUsage: true,
actions: true,
});
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
test("honors the configured compact column set at every width", () => {
expect(getDockerColumnVisibility(["name", "memoryUsage"], 200, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: true,
memoryUsage: false,
cpuUsage: false,
memoryUsage: true,
actions: false,
});
expect(getDockerColumnVisibility(["name", "memoryUsage"], 800, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: true,
actions: false,
});
});
test("keeps a configured state column when there is no name column for the inline dot", () => {
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
});
test("uses every expert column in advanced mode", () => {
@@ -41,9 +48,9 @@ describe("getDockerColumnVisibility", () => {
});
describe("getDockerFooterVisibility", () => {
test("reveals totals only when they fit", () => {
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
test("shows the full compact footer above its threshold", () => {
expect(getDockerFooterVisibility(256, false)).toEqual({ footer: false, cpu: false, memory: false });
expect(getDockerFooterVisibility(257, false)).toEqual({ footer: true, cpu: true, memory: true });
});
test("keeps every total in advanced mode", () => {
+7 -7
View File
@@ -2,7 +2,7 @@ export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage
export const getDockerColumnVisibility = (
configuredColumns: readonly DockerColumn[],
width: number,
_width: number,
isAdvanced: boolean,
): Record<DockerColumn, boolean> => {
const configured = new Set(configuredColumns);
@@ -10,16 +10,16 @@ export const getDockerColumnVisibility = (
return {
name: isVisible("name"),
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")),
host: isVisible("host") && (isAdvanced || width >= 640),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520),
state: isVisible("state"),
host: isVisible("host"),
cpuUsage: isVisible("cpuUsage"),
memoryUsage: isVisible("memoryUsage"),
actions: isVisible("actions"),
};
};
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
footer: isAdvanced || width > 256,
cpu: isAdvanced || width >= 360,
memory: isAdvanced || width >= 480,
cpu: isAdvanced || width > 256,
memory: isAdvanced || width > 256,
});
+18 -22
View File
@@ -159,10 +159,8 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const },
} as const;
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
export function getSizeConfig(width: number, _height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
@@ -796,25 +794,23 @@ export default function DownloadClientsWidget({
/>
</Box>
{(isAdvanced || height >= 96) && (
<WidgetFooter
key={`footer-${displayMode}`}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={statsDisplay.visible}
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
/>
)}
<WidgetFooter
key={`footer-${displayMode}`}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={statsDisplay.visible}
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
/>
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
</Stack>
+13 -19
View File
@@ -68,7 +68,6 @@ 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 || (!isTiny && height >= 120);
const t = useI18n("widget.firewall");
const tCommon = useI18n("common");
@@ -179,7 +178,6 @@ export default function FirewallWidget({
hasError={hasError}
isAdvanced={isAdvanced}
ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={t("error.internalServerError")}
@@ -210,7 +208,6 @@ interface FirewallPanelProps {
hasError: boolean;
isAdvanced: boolean;
ringSize: number;
showInterfaces: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
@@ -234,7 +231,6 @@ const FirewallPanel = ({
hasError,
isAdvanced,
ringSize,
showInterfaces,
accordionValue,
setAccordionValue,
errorLabel,
@@ -283,21 +279,19 @@ const FirewallPanel = ({
)}
</Flex>
{showInterfaces && (
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
)}
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
</Stack>
</Paper>
);
@@ -14,9 +14,21 @@
background-color: transparent;
}
.statItemCompact {
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: background-color 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@media (forced-colors: active) {
.statItem,
.statItemDense {
.statItemDense,
.statItemCompact {
border-color: CanvasText;
}
}
@@ -60,54 +60,57 @@ export default function ImmichServerStatsWidget({
{
key: "users",
enabled: statVisibility.showUsers,
icon: <IconUsers style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconUsers style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("users"),
value: stats.userCount.toLocaleString(locale),
},
{
key: "photos",
enabled: statVisibility.showPhotos,
icon: <IconPhoto style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconPhoto style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("photos"),
value: stats.photoCount.toLocaleString(locale),
},
{
key: "videos",
enabled: statVisibility.showVideos,
icon: <IconVideo style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconVideo style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("videos"),
value: stats.videoCount.toLocaleString(locale),
},
{
key: "storage",
enabled: statVisibility.showStorage,
icon: <IconDatabase style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconDatabase style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("storage"),
value: formatBytes(stats.totalLibraryUsageInBytes),
},
]
.filter((item) => item.enabled)
.map(({ key, icon, label, value }) => (
<StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
<StatItem
key={key}
icon={icon}
label={label}
value={value}
dense={isAdvanced && statsLayout.dense}
compact={!isAdvanced}
/>
));
const statsContent =
isAdvanced || statsLayout.dense ? (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
) : (
<Stack gap="xs">{statItems}</Stack>
);
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
<Stack gap="md" h="100%" p="md">
{statItems}
</Stack>
);
}
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
);
const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
@@ -150,9 +153,10 @@ interface StatItemProps {
label: string;
value: string | number;
dense: boolean;
compact: boolean;
}
function StatItem({ icon, label, value, dense }: StatItemProps) {
function StatItem({ icon, label, value, dense, compact }: StatItemProps) {
if (dense) {
return (
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}>
@@ -167,7 +171,7 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
}
return (
<Group justify="space-between" align="center" className={classes.statItem}>
<Group justify="space-between" align="center" className={compact ? classes.statItemCompact : classes.statItem}>
<Group gap="sm" align="center" wrap="nowrap" miw={0}>
{icon}
<Text size="sm" fw={500} truncate="end">
@@ -181,16 +185,13 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
);
}
export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => {
export const getImmichStatsLayout = (width: number, _height: number, itemCount: number, isAdvanced = false) => {
const count = Math.max(1, itemCount);
if (isAdvanced) {
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
}
const dense = width < 320 || height < 160;
if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true };
if (height < 220) return { columns: Math.min(count, 2), dense: true };
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
return { columns: 1, dense: false };
};
export const getImmichStatVisibility = (
@@ -3,14 +3,11 @@ import { describe, expect, test } from "vitest";
import { getImmichStatsLayout, getImmichStatVisibility } from "./component";
describe("Immich server statistics compact layout", () => {
test("keeps four enabled stats legible in a short widget", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true });
});
test("uses the roomier summary layout when space is available", () => {
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
test("keeps the v1 one-column non-dense layout at every compact size", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 1, dense: false });
});
test("advanced mode shows all four headline stats", () => {
@@ -1,4 +1,8 @@
.card {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.advancedCard {
background-color: transparent;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
}
@@ -5,6 +5,7 @@ import {
ActionIcon,
Anchor,
Badge,
Button,
Card,
Center,
Flex,
@@ -23,6 +24,7 @@ import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
@@ -67,8 +69,7 @@ export default function IndexerManagerWidget({
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = indexersData.flatMap((entry) => entry.indexers);
const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
const unavailableCount = allIndexers.filter(
@@ -88,24 +89,32 @@ export default function IndexerManagerWidget({
className={`indexer-manager-container ${classes.root}`}
h="100%"
direction="column"
gap={isDense ? 4 : "xs"}
p={isDense ? "xs" : "sm"}
gap={isAdvanced ? "xs" : "sm"}
p="sm"
align={isAdvanced ? undefined : "center"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
<Group
className="indexer-manager-title"
align="center"
gap="xs"
wrap="nowrap"
w={isAdvanced ? "100%" : undefined}
>
{testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
<Tooltip label={t("title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
style={hasSmallWidth ? iconSizes.md : iconSizes.xl}
/>
</Tooltip>
{!hasSmallWidth && (
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
{t("title")}
</Text>
)}
{showHealthCounts && (
<Text
size={hasSmallWidth ? "xs" : isAdvanced ? "sm" : "md"}
fw={isAdvanced ? 600 : "bold"}
truncate={isAdvanced ? "end" : undefined}
>
{t("title")}
</Text>
{isAdvanced && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
@@ -117,29 +126,35 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
aria-label={t("testAll")}
>
<IconTestPipe size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
{(isAdvanced || hasSmallHeight) && (
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses(
"indexer-manager-test-action-icon",
isAdvanced && classes.testAction,
actionTargetClasses.root,
)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
aria-label={t("testAll")}
>
<IconTestPipe style={isAdvanced ? iconSizes.sm : iconSizes.xs} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Card
className={combineClasses("indexer-manager-list-container", classes.card)}
className={combineClasses("indexer-manager-list-container", classes.card, isAdvanced && classes.advancedCard)}
w="100%"
p={isDense ? 4 : "xs"}
p="xs"
radius={board.itemRadius}
flex={1}
>
@@ -153,7 +168,7 @@ 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 || error) && (
{(isAdvanced || error) && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
@@ -171,14 +186,18 @@ export default function IndexerManagerWidget({
const presentation = statusPresentation[displayStatus];
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
className={combineClasses(
"indexer-manager-line",
`indexer-manager-${indexer.name}`,
isAdvanced && classes.indexerRow,
)}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
className={combineClasses("indexer-manager-line-anchor", isAdvanced && classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
@@ -189,7 +208,7 @@ export default function IndexerManagerWidget({
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
truncate={isAdvanced ? "end" : undefined}
>
{indexer.name}
</Text>
@@ -201,14 +220,14 @@ export default function IndexerManagerWidget({
) : displayStatus === "healthy" ? (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
color="#2ecc71"
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/>
) : (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
color="#d9534f"
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/>
)}
</Group>
@@ -219,6 +238,25 @@ export default function IndexerManagerWidget({
</ScrollArea>
)}
</Card>
{!isAdvanced && !hasSmallHeight && (
<Button
className="indexer-manager-test-button"
w="100%"
size="xs"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
leftSection={<IconTestPipe style={iconSizes.md} />}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
>
{t("testAll")}
</Button>
)}
</Flex>
);
}
@@ -40,7 +40,6 @@ export default function MediaReleasesWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
@@ -63,7 +62,7 @@ export default function MediaReleasesWidget({
{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} />
<Item item={item} options={options} isAdvanced={isAdvanced} />
</Fragment>
))}
</SimpleGrid>
@@ -82,30 +81,27 @@ interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean;
width: number;
height: number;
}
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => {
const formatReleaseDate = (value: unknown, locale: string) => {
const date = toValidDate(value);
if (!date) return "—";
return Intl.DateTimeFormat(locale, {
month: compact ? "short" : "2-digit",
month: "2-digit",
year: "numeric",
day: "2-digit",
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }),
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
};
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const Item = ({ item, options, isAdvanced }: ItemProps) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.mediaReleases");
const length = formatLength(item.length, item.type, t);
const isCompact = !isAdvanced && (width < 340 || height < 180);
const isTiny = !isAdvanced && (width < 220 || height < 105);
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
const showPoster = isAdvanced || options.layout === "poster";
const showSide = isAdvanced || options.showType || options.showSource;
const href = getSafeApplicationUrl(item.href);
return (
@@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
h="100%"
title={item.title}
style={
@@ -158,57 +154,61 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
}}
/>
)}
<Group justify="space-between" h="100%" wrap="nowrap" gap={isCompact ? "xs" : "sm"}>
<Group justify="space-between" h="100%" wrap="nowrap" gap={isAdvanced ? "sm" : undefined}>
<Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
{showPoster && (
<Image
w={isAdvanced ? 80 : isCompact ? 44 : 60}
w={isAdvanced ? 80 : 60}
src={item.imageUrls.poster}
alt=""
radius="xs"
alt={isAdvanced ? "" : item.title}
radius={isAdvanced ? "xs" : undefined}
loading="lazy"
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}>
<Text size="sm" fw="bold" lineClamp={2}>
{item.title}
</Text>
{item.subtitle !== undefined && !isTiny && (
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}>
{item.subtitle !== undefined && (
<Text size="sm" lineClamp={1}>
{item.subtitle}
</Text>
)}
</Stack>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} />
{length !== undefined && !isTiny && (
<Group
gap={6}
wrap={isAdvanced ? "nowrap" : "wrap"}
style={isAdvanced ? { minWidth: 0 } : { minWidth: 0, rowGap: 0 }}
>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
{length !== undefined && (
<>
<InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</>
)}
{item.producer !== undefined && showExtendedMetadata && (
{item.producer !== undefined && (
<>
<InfoDivider />
<Info label={item.producer} />
</>
)}
{item.rating !== undefined && showExtendedMetadata && (
{item.rating !== undefined && (
<>
<InfoDivider />
<Info icon={IconStarFilled} label={item.rating} />
</>
)}
{item.price !== undefined && showExtendedMetadata && (
{item.price !== undefined && (
<>
<InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} />
</>
)}
</Group>
{item.tags.length > 0 && (isAdvanced || !isCompact) && (
{item.tags.length > 0 && (
<OverflowBadge
size="xs"
groupGap={4}
@@ -29,7 +29,6 @@ export default function MediaServerWidget({
isEditMode,
options,
width,
height,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
@@ -69,8 +68,7 @@ export default function MediaServerWidget({
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256 || height < 96}
isDense={width < 340 || height < 150}
isTiny={width <= 256}
showIntegrationSource={showIntegrationSource}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
@@ -103,20 +101,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isDense: boolean;
showIntegrationSource: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({
request,
isTiny,
isDense,
showIntegrationSource,
canInteract,
options,
}: MediaRequestCardProps) => {
const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href);
@@ -124,11 +114,21 @@ const MediaRequestCard = ({
return (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status} ${classes.card}`}
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
radius={board.itemRadius}
p={isDense ? 6 : "xs"}
withBorder
p="xs"
>
<Image
className="mediaRequests-list-item-background"
src={request.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group
className="mediaRequests-list-item-contents"
h="100%"
@@ -137,56 +137,22 @@ const MediaRequestCard = ({
wrap="nowrap"
gap={0}
>
<Group
className="mediaRequests-list-item-left-side"
h="100%"
gap={isDense ? "xs" : "md"}
wrap="nowrap"
flex={1}
miw={0}
>
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1} miw={0}>
{!isTiny && (
<Image
className="mediaRequests-list-item-poster"
src={request.posterImagePath}
h={isDense ? 32 : 40}
h={40}
w="auto"
radius="sm"
radius="md"
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={0} w="100%" miw={0}>
<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"
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
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}
truncate="end"
style={{ minWidth: 0, flex: 1 }}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
alwaysVisible={isTiny}
/>
) : (
<StatusBadge status={request.status} />
)}
</Group>
<Group justify="space-between" gap="xs" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
<Group gap={4} wrap="nowrap" miw={0}>
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Group gap="xs" wrap="nowrap" miw={0}>
<Text className="mediaRequests-list-item-media-year" size="xs">
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text>
@@ -206,27 +172,52 @@ const MediaRequestCard = ({
</Badge>
)}
</Group>
{!isTiny && (
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size={18}
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="dimmed"
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
truncate="end"
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size="xs"
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="var(--mantine-color-text)"
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
</Group>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw="bold"
title={request.name}
lineClamp={1}
style={{ minWidth: 0 }}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
alwaysVisible
/>
) : (
<StatusBadge status={request.status} />
)}
</Group>
</Stack>
@@ -100,9 +100,7 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = width < 256 || height < 180;
const showSectionTitles = height >= 180;
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
const isTiny = width < 256;
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
return (
@@ -121,13 +119,11 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
>
<Stack gap={4} w="100%">
{showSectionTitles && (
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
)}
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{visibleStats.map((stat) => (
{data.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
@@ -148,11 +144,9 @@ export default function MediaServerWidget({
</Grid>
</Stack>
<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>
)}
<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} mih={0} w="100%">
<Stack gap={4}>
{requestStats.users.map((user) => {
@@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => {
});
describe("getMediaServerColumnVisibility", () => {
test("keeps a stable responsive column model from the zero-sized first render", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
test("keeps every compact column visible at every width", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
});
+66 -57
View File
@@ -48,11 +48,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc
type SortColumn = "user" | "currentlyPlaying" | "status";
type SortState = { column: SortColumn; descending: boolean } | null;
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
user: isAdvanced || width >= 300,
// The user (26%) and status (22%) columns together need a floor here, otherwise the
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
status: isAdvanced || width >= 540,
export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({
user: true,
status: true,
});
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
@@ -171,7 +169,6 @@ export default function MediaServerWidget({
options,
integrationIds,
width,
height,
isEditMode,
displayMode,
}: WidgetComponentProps<"mediaServer">) {
@@ -411,61 +408,73 @@ export default function MediaServerWidget({
</Table.Tbody>
</Table>
</div>
{(isAdvanced || height >= 144) && (
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={6} wrap="nowrap">
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && (
<>
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={isAdvanced ? 6 : 4} wrap="nowrap">
<IconVideo size={isAdvanced ? "var(--mantine-font-size-xs)" : 16} style={{ flexShrink: 0 }} />
<Text size="sm" fw={isAdvanced ? 500 : undefined} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (
<>
{isAdvanced && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
•
</Text>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)}
</Group>
<Group gap={6}>
{uniqueIntegrations.map((integration) => (
<Group
key={integration.integrationId}
gap={6}
align="center"
wrap="nowrap"
pl={2}
pr={8}
py={2}
style={{
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}}
>
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
{(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="xs" fw={500} truncate="end">
{integration.integrationName}
</Text>
)}
</Group>
))}
</Group>
)}
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)}
</Group>
)}
<Group gap={isAdvanced ? 6 : "xs"} wrap="nowrap">
{uniqueIntegrations.map((integration) => (
<Group
key={integration.integrationId}
gap={isAdvanced ? 6 : "xs"}
align="center"
wrap="nowrap"
pl={isAdvanced ? 2 : undefined}
pr={isAdvanced ? 8 : undefined}
py={isAdvanced ? 2 : undefined}
style={
isAdvanced
? {
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}
: undefined
}
>
<Avatar
className="media-server-icon"
src={integration.integrationIcon}
radius={isAdvanced ? "xl" : "xs"}
size={isAdvanced ? 18 : "xs"}
/>
<Text
className="media-server-name"
size={isAdvanced ? "xs" : "sm"}
fw={isAdvanced ? 500 : undefined}
truncate={isAdvanced ? "end" : undefined}
>
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Group>
</Stack>
);
}
@@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus");
@@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
const isDense = width < 220 || height < 120;
const showServerIcon = !isDense && height >= 144;
const showMetadata = width >= 260 && height >= 180;
const showCapacity = width >= 180 && height >= 140;
const isAdvanced = displayMode === "advanced";
const isDense = isAdvanced && (width < 220 || height < 120);
const showServerIcon = !isAdvanced || (!isDense && height >= 144);
const showMetadata = isAdvanced && width >= 260 && height >= 180;
const showCapacity = isAdvanced && 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;
@@ -281,6 +281,7 @@ const WidgetEditPreview = ({
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
displayScale={previewScale}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
@@ -1,7 +1,6 @@
"use client";
import { useMemo } from "react";
import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
@@ -26,13 +25,13 @@ export default function NetworkControllerNetworkStatusWidget({
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
const summaries = useMemo(
() =>
results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
),
[results],
const isAdvanced = displayMode === "advanced";
let results = summaryQuery.data ?? [];
if (isAdvanced) {
results = getUsableWidgetQueryData(summaryQuery) ?? [];
}
const summaries = results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
);
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
@@ -43,13 +42,31 @@ export default function NetworkControllerNetworkStatusWidget({
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
const isAdvanced = displayMode === "advanced";
const indicatorResults =
results.length > 0 || !summaryQuery.error
? results
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={indicatorResults} />
</Group>
);
if (!isAdvanced) {
return (
<Box h="100%" p="sm" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
{options.content === "wifi" ? (
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} />
) : (
<WiredVariant countGuests={countLanGuests} countUsers={countLanUsers} />
)}
</Box>
);
}
if (summaryQuery.isPending || summaries.length === 0) {
return (
<Stack h="100%" gap={0}>
@@ -63,92 +80,50 @@ export default function NetworkControllerNetworkStatusWidget({
);
}
if (isAdvanced) {
return (
<ScrollArea h="100%" p={layout.padding}>
{queryIndicators}
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
</Card>
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
</Card>
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
return (
<Stack p={layout.padding} h="100%" gap={0}>
<ScrollArea h="100%" p={layout.padding}>
{queryIndicators}
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
{layout.showWifi && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} advanced />
</Card>
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} advanced />
</Card>
</SimpleGrid>
</Stack>
</Card>
)}
{layout.showWired && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
</Card>
)}
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
@@ -3,12 +3,27 @@ import { Stack, Text } from "@mantine/core";
export const StatRow = ({
label,
value,
advanced = false,
compact = false,
}: {
label: string;
value: string | number;
advanced?: boolean;
compact?: boolean;
}) => {
if (!advanced) {
return (
<Stack gap={0}>
<Text size="2xl" fw={900} lh={1}>
{value}
</Text>
<Text size="md" c="dimmed">
{label}
</Text>
</Stack>
);
}
return (
<Stack gap={2} align="center" justify="center">
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
@@ -2,22 +2,41 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row";
export const WifiVariant = ({
countGuests,
countUsers,
advanced = false,
compact = false,
horizontal = false,
}: {
countUsers: number;
countGuests: number;
advanced?: boolean;
compact?: boolean;
horizontal?: boolean;
}) => {
const t = useI18n("widget.networkControllerStatus.card");
if (!advanced) {
return (
<>
<Group gap="xs" wrap="nowrap" mb="md">
<IconWifi style={zoomCompensatedSize(24)} />
<Text size="md" fw="bold">
{t("variants.wifi.name")}
</Text>
</Group>
<Stack gap="lg">
<StatRow label={t("users.label")} value={countUsers} />
<StatRow label={t("guests.label")} value={countGuests} />
</Stack>
</>
);
}
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +48,8 @@ export const WifiVariant = ({
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid>
</Stack>
);
@@ -2,22 +2,41 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row";
export const WiredVariant = ({
countGuests,
countUsers,
advanced = false,
compact = false,
horizontal = false,
}: {
countUsers: number;
countGuests: number;
advanced?: boolean;
compact?: boolean;
horizontal?: boolean;
}) => {
const t = useI18n("widget.networkControllerStatus.card");
if (!advanced) {
return (
<>
<Group gap="xs" wrap="nowrap" mb="md">
<IconNetwork style={zoomCompensatedSize(24)} />
<Text size="md" fw="bold">
{t("variants.wired.name")}
</Text>
</Group>
<Stack gap="lg">
<StatRow label={t("users.label")} value={countUsers} />
<StatRow label={t("guests.label")} value={countGuests} />
</Stack>
</>
);
}
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +48,8 @@ export const WiredVariant = ({
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid>
</Stack>
);
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { Box, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -27,13 +27,16 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
const isAdvanced = displayMode === "advanced";
let results = summaryQuery.data ?? [];
if (isAdvanced) {
results = getUsableWidgetQueryData(summaryQuery) ?? [];
}
const summaries = results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
result.summary !== null && result.updatedAt !== undefined,
@@ -51,16 +54,86 @@ export default function NetworkControllerSummaryWidget({
unknown: tDns("status.unknown"),
};
const isDense = height < 160;
const showDetails = height >= 180 && width >= 240;
const columns = width >= 720 ? 2 : 1;
const isAdvanced = displayMode === "advanced";
const indicatorResults =
results.length > 0 || !summaryQuery.error
? results
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={indicatorResults} />
</Group>
);
if (!isAdvanced) {
const summary = summaries[0]?.summary;
return (
<Box h="100%" p="sm" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Center h="100%">
<List spacing="xs" center>
<List.Item
icon={
<StatusIcon
status={summary?.wanStatus ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.wanStatus)]}
style={iconSizes.xl}
/>
}
>
{t("card.wan")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary?.www.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.www.status)]}
style={iconSizes.xl}
/>
}
>
<Text>
WWW
<Text c="dimmed" size="md" ms="xs" span>
{summary?.www.latency ?? 0}ms
</Text>
</Text>
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary?.wifi.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.wifi.status)]}
style={iconSizes.xl}
/>
}
>
{t("card.wifi")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary?.vpn.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.vpn.status)]}
style={iconSizes.xl}
/>
}
>
<Text>
{t("card.vpn.label")}
<Text c="dimmed" size="md" ms="xs" span>
{t("card.vpn.countConnected", { count: summary?.vpn.users ?? 0 })}
</Text>
</Text>
</List.Item>
</List>
</Center>
</Box>
);
}
if (isPending || summaries.length === 0) {
return (
<Stack h="100%" gap={0}>
@@ -74,190 +147,82 @@ export default function NetworkControllerSummaryWidget({
);
}
if (isAdvanced) {
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return (
<ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators}
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card
key={integration.id}
p="sm"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{integration.kind}
</Text>
</Stack>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="10px" c="dimmed">
{t("advanced.updated")}
</Text>
<Text size="xs" c="dimmed" ta="right">
{new Date(updatedAt).toLocaleString(locale)}
</Text>
</Stack>
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<StatusIcon
status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]}
size="var(--mantine-font-size-md)"
/>
<Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{statusLabels[getBinaryStatusKey(section.status)]}
</Text>
</Group>
{section.metrics.length > 0 && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
{section.metrics.map((metric) => (
<Stack key={metric.key} gap={0}>
<Text size="10px" c="dimmed">
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return (
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
<ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators}
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
{summaries.map(({ integration, summary }) => (
<Card
key={integration.id}
p={isDense ? "xs" : "sm"}
withBorder={summaries.length > 1}
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
{summaries.length > 1 && (
<Group justify="space-between" wrap="nowrap">
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} p="sm" withBorder bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Badge size="xs" variant="light">
<Text size="xs" c="dimmed">
{integration.kind}
</Badge>
</Group>
)}
<List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
<List.Item
icon={
<StatusIcon
status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
{t("card.wan")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.www.status}
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
<Text>
{t("card.web")}
<Text c="dimmed" size="sm" ms="xs" span>
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
</Text>
</Text>
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.wifi.status}
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
{t("card.wifi")}
{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{showDetails && (
<List.Item
icon={
<StatusIcon
status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
</Stack>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="10px" c="dimmed">
{t("advanced.updated")}
</Text>
<Text size="xs" c="dimmed" ta="right">
{new Date(updatedAt).toLocaleString(locale)}
</Text>
</Stack>
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
{t("card.lan")} · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item
icon={
<StatusIcon
status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
<Text>
{t("card.vpn.label")}
<Text c="dimmed" size="sm" ms="xs" span>
{t("card.vpn.countConnected", { count: summary.vpn.users })}
</Text>
</Text>
</List.Item>
</List>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<StatusIcon
status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]}
style={iconSizes.md}
/>
<Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{statusLabels[getBinaryStatusKey(section.status)]}
</Text>
</Group>
{section.metrics.length > 0 && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
{section.metrics.map((metric) => (
<Stack key={metric.key} gap={0}>
<Text size="10px" c="dimmed">
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
@@ -280,26 +245,23 @@ const getMatrixSectionLabel = (
return t(`card.${key}` as never);
};
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const StatusIcon = ({
status,
label,
size,
style,
}: {
status?: "enabled" | "disabled";
label: string;
size?: number | string;
style?: CSSProperties;
}) => {
const mantineTheme = useMantineTheme();
if (status === "enabled") {
return <IconCircleCheckFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.green[6]} />;
return <IconCircleCheckFilled aria-label={label} style={style} color={mantineTheme.colors.green[6]} />;
}
if (status === "disabled") {
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.red[6]} />;
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.red[6]} />;
}
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.gray[6]} />;
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.gray[6]} />;
};
@@ -52,21 +52,22 @@ export default function NotificationsWidget({
const failedIntegrations = notificationIntegrations.filter(
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
);
const isAdvanced = displayMode === "advanced";
const isDense = width < 280 || height < 180;
const isRoomy = width >= 360 && height >= 220;
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
const notificationDisplay = getNotificationDisplay({
displayMode,
hideLogos: options.hideLogos,
isRoomy,
bodyLineClamp,
isRoomy: isAdvanced && isRoomy,
bodyLineClamp: isAdvanced ? bodyLineClamp : 4,
});
const columns = width >= 720 ? 2 : 1;
const spacing = isRoomy ? "sm" : "xs";
const columns = isAdvanced && width >= 720 ? 2 : 1;
const spacing = !isAdvanced || isRoomy ? "sm" : "xs";
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "xs" : "sm"}>
<Stack w="100%" gap={isAdvanced ? "xs" : "sm"}>
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -93,34 +94,39 @@ export default function NotificationsWidget({
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
className={columns > 1 ? classes.card : classes.row}
className={isAdvanced ? (columns > 1 ? classes.card : classes.row) : undefined}
radius={board.itemRadius}
w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"}
style={{
color: "inherit",
textDecoration: "none",
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
<Flex gap={!isAdvanced || !isDense ? "sm" : "xs"} align="flex-start" w="100%">
{notificationDisplay.showLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size={isDense ? "xs" : "sm"}
size={isAdvanced && isDense ? "xs" : "sm"}
radius={board.itemRadius}
/>
)}
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
<Flex gap={!isAdvanced || 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}>
<Text
fz={isAdvanced && isRoomy ? "md" : "sm"}
fw={isAdvanced ? 600 : undefined}
lh={isAdvanced ? 1.25 : "sm"}
lineClamp={isAdvanced && isDense ? 1 : 2}
>
{notification.title}
</Text>
)}
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
size={isAdvanced && isDense ? "xs" : "sm"}
lineClamp={notificationDisplay.bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
@@ -134,7 +140,7 @@ export default function NotificationsWidget({
? (notification.source?.name ?? notification.integrationName)
: undefined
}
dense={isDense}
dense={isAdvanced && isDense}
/>
</Flex>
</Flex>
@@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den
return (
<Group gap={5} align="center" wrap="nowrap">
<IconClock
aria-hidden
style={dense ? iconSizes.xs : iconSizes.md}
color="var(--mantine-color-dimmed)"
/>
<IconClock aria-hidden style={dense ? iconSizes.xs : iconSizes.md} color="var(--mantine-color-dimmed)" />
<Text size={dense ? "xs" : "sm"} c="dimmed">
{timeAgo}
</Text>
@@ -16,8 +16,7 @@
gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.heroExpanded {
@@ -98,9 +97,7 @@
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
@@ -110,7 +107,11 @@
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 12px);
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(12px * var(--board-canvas-ui-scale, 1))
);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -121,9 +122,13 @@
}
.statValue {
font-size: clamp(14px, 5.5cqi, 24px);
font-size: clamp(
calc(14px * var(--board-canvas-ui-scale, 1)),
calc(5.5cqi * var(--board-canvas-ui-scale, 1)),
calc(24px * var(--board-canvas-ui-scale, 1))
);
font-weight: 700;
color: var(--mantine-color-text);
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
}
@@ -75,7 +75,8 @@ export default function PaperlessNgxWidget({
integrationIds,
options,
width,
height,
displayScale = 1,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useI18n("widget.paperlessNgx");
const tCommon = useI18n("common");
@@ -116,19 +117,22 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height)));
const ringSize = getRingSize(Math.min(width, height * 2));
const iconSize = getIconSize(width);
let responsiveWidth = width;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
}
const gridCols = getGridCols(responsiveWidth);
const ringSize = getRingSize(responsiveWidth);
const iconSize = getIconSize(responsiveWidth);
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 && height >= 120;
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
([, optionKey]) => options[optionKey as keyof typeof options],
);
const heroPartRenderers = {
@@ -205,9 +209,9 @@ function computeInboxProgress(total: number, inbox: number): number {
return Math.round((inbox / total) * 100);
}
function getGridCols(width: number, height: number): number {
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5);
return match?.cols ?? 1;
}
function getRingSize(width: number): number {
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
defaultValue: {},
});
const isAdvanced = displayMode === "advanced";
const columns = Math.max(1, Math.floor(width / 420));
const columns = isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1;
const relativeDateOptions = useMemo(
() => ({
@@ -24,6 +24,18 @@
flex-shrink: 0;
}
.backgroundImage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(5px);
transform: scaleX(-1.05) scaleZ(-1.05);
opacity: 0.25;
}
@media (prefers-reduced-motion: reduce) {
.entry {
transition: none;
+104 -61
View File
@@ -1,6 +1,6 @@
"use client";
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core";
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react";
import dayjs from "dayjs";
@@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
descriptionLines,
});
const spacing = isRoomy ? "sm" : "xs";
const isNarrow = width < 128 * 3;
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? (isTiny ? 4 : "xs") : "sm"}>
{warning && (
<Alert
role="presentation"
@@ -118,67 +119,109 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
<output>{warning}</output>
</Alert>
)}
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
{feed.entries.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 && entryDisplay.showImage && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt=""
w={isRoomy ? 140 : isDense ? 64 : 96}
h={isRoomy ? 96 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
)}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text
dir={languageDir}
fz={isRoomy ? "md" : "sm"}
fw={600}
lh={1.25}
lineClamp={isAdvanced ? undefined : 2}
>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
{feedDescriptionToText(feedEntry.description)}
</Text>
{isAdvanced ? (
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
{feed.entries.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 && entryDisplay.showImage && (
<Image
loading="lazy"
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={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
isAdvanced && feedEntry.published
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
: undefined
}
/>
</Flex>
</Group>
</Card>
);
})}
</SimpleGrid>
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25}>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm">
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
feedEntry.published ? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z") : undefined
}
/>
</Flex>
</Group>
</Card>
);
})}
</SimpleGrid>
) : (
<Stack w="100%" gap="sm">
{feed.entries.map((feedEntry) => {
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
return (
<Card
key={feedEntry.id}
component={href ? "a" : "div"}
href={href}
radius={board.itemRadius}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
w="100%"
p="sm"
title={feedEntry.title}
>
{feedEntry.enclosure !== undefined && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="" />
)}
<Group wrap="nowrap">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt={feedEntry.title}
w={140}
h={140}
radius="sm"
fit="cover"
/>
)}
<Flex gap="sm" direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={options.textLinesClamp}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
);
})}
</Stack>
)}
</ScrollArea>
);
}
@@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({
if (entityError && entity === undefined) throw entityError;
const isTiny = isSmartHomeTiny(width, height);
const isAdvanced = displayMode === "advanced";
const isTiny = isAdvanced ? isSmartHomeTiny(width, height) : width < 128;
const advancedAttributes = [
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
{ key: "icon" as const, value: entity?.attributes.icon },
].filter(({ value }) => typeof value === "string" && value.length > 0);
const displayName =
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
isAdvanced && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName;
const knownStates = {
@@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({
}}
>
<Center h="100%" w="100%">
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{displayName}
</Text>
<Stack align="center" gap={isAdvanced && isTiny ? 4 : "md"} p={isAdvanced ? "xs" : undefined} maw="100%">
{isAdvanced ? (
<>
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{displayName}
</Text>
</>
) : (
<>
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"} lineClamp={2} maw="100%">
{displayName}
</Text>
<Text ta="center" size={isTiny ? "xs" : "lg"} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
</>
)}
{queryErrorLabel && (
<Text size="xs" c="orange" ta="center" lineClamp={1} maw="100%" aria-live="polite">
{queryErrorLabel}
</Text>
)}
{displayMode === "advanced" && entity && (
{isAdvanced && entity && (
<>
<Group justify="center" gap={4}>
{advancedAttributes.map(({ key, value }) => (
@@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
export function AveragesSection({
stats,
width = 500,
compactSurface = false,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={6} h="100%">
<SectionLabel>
<SectionLabel uppercase={compactSurface}>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
@@ -25,6 +33,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={formatStatsSpeed(stats.download)}
label={t("avgDownload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
@@ -32,6 +41,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={formatStatsSpeed(stats.upload)}
label={t("avgUpload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
@@ -39,6 +49,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={`${stats.ping.avg.toFixed(1)} ms`}
label={t("avgPing")}
compact
legacySurface={compactSurface}
/>
</SimpleGrid>
</Stack>
@@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
@@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
<LatestResultSection
result={combined.latestResult}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
<AveragesSection
stats={combined.stats}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
);
const compactSections = getCompactSections(height, {
latest: Boolean(latest),
chart: hasChart,
averages: Boolean(averages),
});
if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
@@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text>
)}
{(compactSections.latest || compactSections.averages) && (
{(latest || averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{compactSections.chart && (
{hasChart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} uppercaseLabels />
</div>
)}
{noSectionsEnabled && (
@@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
export function LatestResultSection({
result,
width = 500,
compactSurface = false,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
const timestamp = Intl.DateTimeFormat(locale, {
@@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
return (
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<SectionLabel uppercase={compactSurface}>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp}
</Text>
@@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color="blue"
value={formatResultSpeed(result, "download")}
label={t("download")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
label={t("upload")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
label={t("ping")}
legacySurface={compactSurface}
/>
{result.healthy !== null && (
<SpeedStatCard
@@ -55,6 +66,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
label={t("status")}
legacySurface={compactSurface}
/>
)}
</SimpleGrid>
@@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string):
export function RecentResultsSection({
results,
showPingGraph,
uppercaseLabels = false,
}: {
results: SpeedtestTrackerResult[];
showPingGraph: boolean;
uppercaseLabels?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
<SectionLabel>{t("recentResults")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResults")}</SectionLabel>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<SpeedSubChart results={results} showLabel={showPingGraph} />
{showPingGraph && <PingSubChart results={results} />}
<SpeedSubChart results={results} showLabel={showPingGraph} uppercaseLabels={uppercaseLabels} />
{showPingGraph && <PingSubChart results={results} uppercaseLabels={uppercaseLabels} />}
</div>
</Stack>
);
}
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
function SpeedSubChart({
results,
showLabel,
uppercaseLabels,
}: {
results: SpeedtestTrackerResult[];
showLabel: boolean;
uppercaseLabels: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
{showLabel && <SectionLabel>{t("recentResultsSpeed")}</SectionLabel>}
{showLabel && <SectionLabel uppercase={uppercaseLabels}>{t("recentResultsSpeed")}</SectionLabel>}
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
</div>
@@ -145,13 +155,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult
);
}
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) {
const t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResultsPing")}</SectionLabel>
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <PingHistoryChart results={results} height={height} />}
</div>
@@ -1,9 +1,15 @@
import type { ReactNode } from "react";
import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) {
return (
<Text size="xs" fw={600} c="dimmed">
<Text
size="xs"
fw={600}
c="dimmed"
tt={uppercase ? "uppercase" : undefined}
style={{ letterSpacing: uppercase ? "0.05em" : undefined }}
>
{children}
</Text>
);
@@ -13,9 +13,17 @@ export interface SpeedStatCardProps {
value: string;
label: string;
compact?: boolean;
legacySurface?: boolean;
}
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
export function SpeedStatCard({
icon: Icon,
color,
value,
label,
compact = false,
legacySurface = false,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const isWide = width > height + 20;
@@ -23,7 +31,8 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
const surfaceColor = `var(--mantine-color-${color}-filled)`;
const accentColor = `var(--mantine-color-${color}-5)`;
const surfaceAlpha = compact ? 0.1 : 0.14;
const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground;
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
return (
@@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
withBorder
withBorder={!legacySurface}
bg={surfaceBackground}
h="100%"
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
+25 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [
{ minimumWidth: 0, columns: 1 },
] as const;
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => {
const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => {
if (!isAdvanced) {
return {
columns: 1,
showSecondaryText: height >= 160 && width >= 260,
showTemperature: height >= 100 && width >= 220,
showSecondaryText: false,
showTemperature: true,
};
}
@@ -113,6 +113,7 @@ const SystemDiskCard = ({
showTemperature,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
const scheme = useMantineColorScheme();
const t = useI18n("widget.systemDisks");
const valueRef = useRef<HTMLParagraphElement>(null);
const [valueFits, setValueFits] = useState(true);
@@ -149,6 +150,10 @@ const SystemDiskCard = ({
.join(" · ");
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const legacyBackground = scheme.colorScheme === "dark" ? "dark.7" : "gray.1";
const cardBackground = isAdvanced ? backgroundColor : legacyBackground;
const progressBackground = healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)";
const legacyProgressColor = healthy ? "green" : "red";
return (
<Tooltip
@@ -160,13 +165,13 @@ const SystemDiskCard = ({
<Card
radius={board.itemRadius}
py="xs"
withBorder
bg={backgroundColor}
style={{ overflow: "hidden", position: "relative", borderColor }}
withBorder={isAdvanced}
bg={cardBackground}
style={{ overflow: "hidden", position: "relative", borderColor: isAdvanced ? borderColor : undefined }}
>
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} size="sm" truncate="end">
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
{deviceName}
</Text>
{integrationName && (
@@ -174,7 +179,11 @@ const SystemDiskCard = ({
{integrationName}
</Text>
)}
<Text ref={valueRef} size="sm" style={{ visibility: valueFits ? "visible" : "hidden" }}>
<Text
ref={valueRef}
size={isAdvanced ? "sm" : undefined}
style={{ visibility: valueFits ? "visible" : "hidden" }}
>
<span>{displayText}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</Text>
@@ -195,10 +204,13 @@ const SystemDiskCard = ({
)}
</div>
<div style={{ flexShrink: 0 }}>
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
{showTemperature && (hasTemperature || isAdvanced) ? (
<Text size={isAdvanced ? "sm" : undefined}>{temperatureText}</Text>
) : null}
</div>
</Group>
<Box
bg={isAdvanced ? undefined : legacyProgressColor}
style={{
position: "absolute",
top: 0,
@@ -207,7 +219,7 @@ const SystemDiskCard = ({
height: "100%",
zIndex: 0,
display: showBackgroundBar ? "block" : "none",
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)",
backgroundColor: isAdvanced ? progressBackground : undefined,
}}
></Box>
</Card>
@@ -219,7 +231,6 @@ export default function SystemResources({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemDisks">) {
const t = useI18n("widget.systemDisks");
@@ -247,8 +258,8 @@ export default function SystemResources({
});
const isAdvanced = displayMode === "advanced";
const layout = useMemo(
() => getSystemDisksLayout(width, height, disks.length, isAdvanced),
[disks.length, height, isAdvanced, width],
() => getSystemDisksLayout(width, disks.length, isAdvanced),
[disks.length, isAdvanced, width],
);
const queryIndicators = (
<Group gap={0}>
@@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({
usageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
usageOverTime: {
up: number;
@@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({
}[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
const t = useI18n("widget.systemResources.card");
@@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({
yAxisProps={{ domain: [0, "dataMax"] }}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
return (
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -23,6 +23,7 @@ export const CommonChart = ({
yAxisProps,
lastValue,
chartType = "line",
advanced = false,
}: {
data: Record<string, any>[];
dataKey: string;
@@ -34,30 +35,32 @@ export const CommonChart = ({
yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string;
chartType?: "line" | "area";
advanced?: boolean;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const opacity = board.opacity / 100;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
if (advanced) {
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor;
}
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
return (
<Card
ref={ref}
h={"100%"}
pos={"relative"}
style={{ overflow: "hidden", border: `1px solid ${borderColor}` }}
p={0}
bg={backgroundColor}
radius={board.itemRadius}
>
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
{data.length > 1 && height > 40 && !hovered && (
<Group
pos={"absolute"}
@@ -70,7 +73,13 @@ export const CommonChart = ({
style={{ zIndex: 2, pointerEvents: "none" }}
align="center"
>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
{showIcon && (
<Icon
color="var(--mantine-color-dimmed)"
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
stroke={1.5}
/>
)}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title}
@@ -83,7 +92,7 @@ export const CommonChart = ({
)}
</Group>
)}
{data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
@@ -93,13 +102,19 @@ export const CommonChart = ({
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
{showIcon && (
<Icon
color="var(--mantine-color-dimmed)"
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
stroke={1.5}
/>
)}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
{lastValue && (
{advanced && lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
@@ -119,13 +134,7 @@ export const CommonChart = ({
withYAxis={false}
withDots={false}
bg={backgroundColor}
styles={{
root: {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
backgroundColor,
},
}}
styles={{ root: chartRootStyle }}
tooltipAnimationDuration={200}
tooltipProps={{
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
@@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({
cpuUsageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({
chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
@@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({
gpuUsageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
gpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({
chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
@@ -12,11 +12,13 @@ export const SystemResourceMemoryChart = ({
totalCapacityInBytes,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
memoryUsageOverTime: number[];
totalCapacityInBytes: number;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card");
@@ -35,6 +37,7 @@ export const SystemResourceMemoryChart = ({
title={t("memory")}
icon={IconBrain}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
chartType={hasShadow ? "area" : "line"}
@@ -12,11 +12,13 @@ export const NetworkTrafficChart = ({
isUp,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
usageOverTime: number[];
isUp: boolean;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card");
@@ -36,6 +38,7 @@ export const NetworkTrafficChart = ({
lastValue={formatByteRate(Math.round(latest))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
@@ -68,7 +68,7 @@ export const getVisibleSystemCharts = ({
configuredCharts,
hasGpu,
hasNetwork,
height,
height: _height,
isAdvanced,
}: {
configuredCharts: readonly SystemChart[];
@@ -81,7 +81,7 @@ export const getVisibleSystemCharts = ({
const availableCharts = selectedCharts.filter(
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
);
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
return [...availableCharts];
};
export default function SystemResources({
@@ -235,10 +235,7 @@ const SystemCharts = ({
const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = useMemo(
() => (isAdvanced ? getAdvancedChartColumns(width) : 1),
[isAdvanced, width],
);
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
const compactChartGap = 8;
const compactRowHeight = 56;
const chartHeight = isAdvanced
@@ -269,6 +266,7 @@ const SystemCharts = ({
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
</Box>
)}
@@ -279,6 +277,7 @@ const SystemCharts = ({
totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
</Box>
)}
@@ -288,6 +287,7 @@ const SystemCharts = ({
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
</Box>
)}
@@ -299,12 +299,14 @@ const SystemCharts = ({
isUp={false}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
isUp
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
</Group>
) : (
@@ -313,6 +315,7 @@ const SystemCharts = ({
usageOverTime={networkItems}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/>
</Box>
))}
+6 -25
View File
@@ -65,17 +65,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 300 || height < 160);
const showTitle = isAdvanced || height >= 112;
const showLine = isAdvanced || width >= 200;
const showPlatform = isAdvanced || width >= 320;
return (
<Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}>
{showTitle && (
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
{t("title", { station: station.label })}
</Text>
)}
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
{t("title", { station: station.label })}
</Text>
{compactStaleWarning && displayMode === "compact" && (
<Group gap={2} wrap="nowrap">
<IconAlertTriangle
@@ -113,8 +108,6 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
key={`${entry.timestamp.toISOString()}-${entry.location}`}
entry={entry}
dense={isDense}
showLine={showLine}
showPlatform={showPlatform}
locale={locale}
/>
))
@@ -144,23 +137,11 @@ interface TimetableEntryView {
platform: { name: string; hasChanged: boolean } | null;
}
function DepartureRow({
entry,
dense,
showLine,
showPlatform,
locale,
}: {
entry: TimetableEntryView;
dense: boolean;
showLine: boolean;
showPlatform: boolean;
locale: string;
}) {
function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; dense: boolean; locale: string }) {
return (
<Group justify="space-between" w="100%" wrap="nowrap">
<Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}>
{showLine && entry.line && (
{entry.line && (
<Badge
size="sm"
color={entry.line.color ?? undefined}
@@ -185,7 +166,7 @@ function DepartureRow({
</Text>
</Group>
{showPlatform && entry.platform && (
{entry.platform && (
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
{entry.platform.name}
</Text>
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component";
import { getAdvancedColumnWidth, getTracearrSectionVisibility } from "./component";
import { attachTracearrSource } from "./source";
describe("Tracearr source ownership", () => {
@@ -30,32 +30,6 @@ describe("Tracearr advanced layout", () => {
});
});
describe("Tracearr compact disclosure", () => {
it("keeps urgent violations visible in a short widget", () => {
expect(
getCompactSectionVisibility({
height: 160,
showStreams: true,
showViolations: true,
hasViolations: true,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: false });
});
it("reveals secondary history as height becomes available", () => {
expect(
getCompactSectionVisibility({
height: 400,
showStreams: true,
showViolations: true,
hasViolations: false,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: true });
});
});
describe("Tracearr advanced disclosure", () => {
it("shows all sections even when compact presentation toggles are disabled", () => {
expect(
+10 -48
View File
@@ -33,54 +33,27 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
recentActivity: isAdvanced || options.showRecentActivity,
});
export const getCompactSectionVisibility = ({
height,
showStreams,
showViolations,
hasViolations,
showRecentActivity,
}: {
height: number;
showStreams: boolean;
showViolations: boolean;
hasViolations: boolean;
showRecentActivity: boolean;
}) => ({
violations: showViolations && (hasViolations || height >= 240 || !showStreams),
recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))),
});
export default function TracearrWidget({
options,
integrationIds,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"tracearr">) {
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
return (
<TracearrContent
integrationIds={integrationIds}
options={options}
width={width}
height={height}
displayMode={displayMode}
/>
);
return <TracearrContent integrationIds={integrationIds} options={options} width={width} displayMode={displayMode} />;
}
interface TracearrContentProps {
integrationIds: string[];
options: WidgetComponentProps<"tracearr">["options"];
width: number;
height: number;
displayMode: "compact" | "advanced";
}
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
function TracearrContent({ integrationIds, options, width, displayMode }: TracearrContentProps) {
const t = useI18n("widget.tracearr");
const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? [];
@@ -157,7 +130,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced);
const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible);
const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }),
);
@@ -167,15 +139,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }),
);
const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit);
const showSource = isAdvanced || dashboardData.length > 1;
const compactSections = getCompactSectionVisibility({
height,
showStreams: sectionVisibility.streams,
showViolations: sectionVisibility.violations,
hasViolations: sourcedViolations.length > 0,
showRecentActivity: sectionVisibility.recentActivity,
});
if (isAdvanced) {
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
@@ -196,18 +160,18 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text>
{sectionVisibility.stats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} />
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} transparent />
)}
{sectionVisibility.streams && (
<StreamsList streams={streams} width={columnWidth} showSource={showSource} />
<StreamsList streams={sourcedStreams} width={columnWidth} showSource={showSource} transparent />
)}
</Stack>
<Stack gap="sm">
{sectionVisibility.violations && (
<ViolationsList violations={sourcedViolations} showSource={showSource} />
<ViolationsList violations={sourcedViolations} showSource={showSource} transparent />
)}
{sectionVisibility.recentActivity && (
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} transparent />
)}
</Stack>
</SimpleGrid>
@@ -231,12 +195,10 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
{sectionVisibility.stats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />
)}
{compactSections.violations && (
<ViolationsList violations={sourcedViolations.slice(0, compactLimit)} showSource={showSource} />
)}
{sectionVisibility.streams && <StreamsList streams={streams} width={width} showSource={showSource} />}
{compactSections.recentActivity && (
<RecentActivityList sessions={sourcedRecentActivity.slice(0, compactLimit)} showSource={showSource} />
{sectionVisibility.streams && <StreamsList streams={sourcedStreams} width={width} showSource={showSource} />}
{sectionVisibility.violations && <ViolationsList violations={sourcedViolations} showSource={showSource} />}
{sectionVisibility.recentActivity && (
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center">
@@ -10,16 +10,18 @@ type SourcedTracearrHistorySession = SourcedTracearrItem<TracearrHistorySession>
export function RecentActivityList({
sessions,
showSource,
transparent = false,
}: {
sessions: SourcedTracearrHistorySession[];
showSource: boolean;
transparent?: boolean;
}) {
const t = useI18n("widget.tracearr");
const locale = useCurrentIntlLocale();
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
{t("recentActivity.title")}
</Text>
{sessions.length === 0 ? (
@@ -35,7 +37,7 @@ export function RecentActivityList({
: session.mediaTitle;
return (
<Paper key={session.key} p="sm" radius="lg" bg="transparent">
<Paper key={session.key} p="sm" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
@@ -8,10 +8,12 @@ export function StatsBar({
stats,
summary,
width,
transparent = false,
}: {
stats: TracearrDashboardData["stats"];
summary: TracearrDashboardData["streams"]["summary"];
width: number;
transparent?: boolean;
}) {
const t = useI18n("widget.tracearr");
const cols = width > 400 ? 4 : width > 250 ? 2 : 1;
@@ -22,29 +24,43 @@ export function StatsBar({
icon={<IconVideo size="var(--mantine-font-size-md)" />}
label={t("stats.activeStreams")}
value={stats.activeStreams}
transparent={transparent}
/>
<StatCard
icon={<IconUsers size="var(--mantine-font-size-md)" />}
label={t("stats.totalUsers")}
value={stats.totalUsers}
transparent={transparent}
/>
<StatCard
icon={<IconDevices size="var(--mantine-font-size-md)" />}
label={t("stats.transcodes")}
value={`${summary.transcodes}/${summary.total}`}
transparent={transparent}
/>
<StatCard
icon={<IconNetwork size="var(--mantine-font-size-md)" />}
label={t("stats.bandwidth")}
value={summary.totalBitrate}
transparent={transparent}
/>
</SimpleGrid>
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
function StatCard({
icon,
label,
value,
transparent,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
transparent: boolean;
}) {
return (
<Paper p="xs" radius="lg" bg="transparent">
<Paper p="xs" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group gap={4} wrap="nowrap">
{icon}
<Stack gap={0} style={{ overflow: "hidden" }}>
@@ -13,10 +13,12 @@ export function StreamsList({
streams,
width,
showSource,
transparent = false,
}: {
streams: SourcedTracearrStream[];
width: number;
showSource: boolean;
transparent?: boolean;
}) {
const t = useI18n("widget.tracearr");
@@ -31,7 +33,13 @@ export function StreamsList({
return (
<Stack gap="xs">
{streams.map((stream) => (
<StreamCard key={stream.key} stream={stream} compact={width < 300} showSource={showSource} />
<StreamCard
key={stream.key}
stream={stream}
compact={width < 300}
showSource={showSource}
transparent={transparent}
/>
))}
</Stack>
);
@@ -41,10 +49,12 @@ function StreamCard({
stream,
compact,
showSource,
transparent,
}: {
stream: SourcedTracearrStream;
compact: boolean;
showSource: boolean;
transparent: boolean;
}) {
const t = useI18n("widget.tracearr");
const progressPercent =
@@ -65,7 +75,12 @@ function StreamCard({
: null;
return (
<Paper p="xs" radius="lg" bg="transparent" style={{ position: "relative", overflow: "hidden" }}>
<Paper
p="xs"
radius="lg"
bg={transparent ? "transparent" : undefined}
style={{ position: "relative", overflow: "hidden" }}
>
{stream.posterUrl && (
<Box
style={{
@@ -11,16 +11,18 @@ type SourcedTracearrViolation = SourcedTracearrItem<TracearrViolation>;
export function ViolationsList({
violations,
showSource,
transparent = false,
}: {
violations: SourcedTracearrViolation[];
showSource: boolean;
transparent?: boolean;
}) {
const t = useI18n("widget.tracearr");
const locale = useCurrentIntlLocale();
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
{t("violations.title")}
</Text>
{violations.length === 0 ? (
@@ -30,7 +32,7 @@ export function ViolationsList({
) : (
<Stack gap="xs">
{violations.map((violation) => (
<Paper key={violation.key} p="xs" radius="lg" bg="transparent">
<Paper key={violation.key} p="xs" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<IconAlertTriangle
@@ -39,8 +39,8 @@ describe("Traefik source ownership", () => {
});
describe("Traefik advanced disclosure", () => {
test("shows every protocol regardless of compact toggles and height", () => {
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]);
test("shows every protocol in advanced mode regardless of compact toggles", () => {
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, true)).toEqual(["http", "tcp", "udp"]);
});
test("shows every unique entry point regardless of compact width limits", () => {
+4 -6
View File
@@ -79,12 +79,11 @@ export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource
export function getTraefikProtocolKeys(
options: Pick<WidgetComponentProps<"traefik">["options"], "showTcp" | "showUdp">,
height: number,
isAdvanced: boolean,
): ProtocolKey[] {
const protocols: ProtocolKey[] = ["http"];
if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp");
if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp");
if (isAdvanced || options.showTcp) protocols.push("tcp");
if (isAdvanced || options.showUdp) protocols.push("udp");
return protocols;
}
@@ -105,7 +104,6 @@ function TraefikWidgetContent({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"traefik">) {
const t = useI18n("widget.traefik");
@@ -143,7 +141,7 @@ function TraefikWidgetContent({
combined.failedEndpoints.length;
const totalWarnings =
getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp);
const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced);
const protocolKeys = getTraefikProtocolKeys(options, isAdvanced);
const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced);
const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null;
@@ -194,7 +192,7 @@ function TraefikWidgetContent({
))}
</div>
{(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && (
{(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (
<div className={classes.entryPoints}>
{entryPoints.map((entryPoint) => (
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
@@ -6,13 +6,9 @@ describe("Umami layout", () => {
test.each([
{ width: 260, height: 119, property: "isDense" as const, expected: true },
{ width: 260, height: 120, property: "isDense" as const, expected: false },
{ width: 260, height: 139, property: "showXAxis" as const, expected: false },
{ width: 260, height: 140, property: "showXAxis" as const, expected: true },
{ width: 260, height: 95, property: "showSecondaryStats" as const, expected: false },
{ width: 260, height: 96, property: "showSecondaryStats" as const, expected: true },
{ width: 259, height: 150, property: "showDetailedStats" as const, expected: false },
{ width: 260, height: 149, property: "showDetailedStats" as const, expected: false },
{ width: 260, height: 150, property: "showDetailedStats" as const, expected: true },
{ width: 100, height: 80, property: "showXAxis" as const, expected: true },
{ width: 100, height: 80, property: "showSecondaryStats" as const, expected: true },
{ width: 100, height: 80, property: "showDetailedStats" as const, expected: true },
])("sets $property to $expected at $width x $height", ({ width, height, property, expected }) => {
expect(getUmamiLayout({ width, height, displayMode: "compact" })[property]).toBe(expected);
});
+3 -3
View File
@@ -8,9 +8,9 @@ export const getUmamiLayout = ({ width, height, displayMode }: UmamiLayoutInput)
const isAdvanced = displayMode === "advanced";
return {
isDense: !isAdvanced && height < 120,
showXAxis: isAdvanced || height >= 140,
showSecondaryStats: isAdvanced || height >= 96,
showDetailedStats: isAdvanced || (width >= 260 && height >= 150),
showXAxis: true,
showSecondaryStats: true,
showDetailedStats: true,
stackAdvancedContent: isAdvanced && width < 900,
};
};
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import { aggregateUptimeKumaDashboards } from "./aggregate";
import { getCompactStatLimit, getCompactStatPriority } from "./component";
describe("aggregateUptimeKumaDashboards", () => {
it("weights uptime by monitors instead of integrations", () => {
@@ -30,26 +29,3 @@ describe("aggregateUptimeKumaDashboards", () => {
expect(combined.averageUptimePercent).toBe(25);
});
});
describe("getCompactStatLimit", () => {
it("reserves compact height for the uptime summary", () => {
expect(getCompactStatLimit(150, true, 4)).toBe(0);
expect(getCompactStatLimit(220, true, 4)).toBe(2);
});
it("uses the space for metrics when the summary is disabled", () => {
expect(getCompactStatLimit(100, false, 4)).toBe(2);
expect(getCompactStatLimit(240, false, 4)).toBe(4);
});
});
describe("getCompactStatPriority", () => {
it("puts active failures before healthy totals", () => {
expect(getCompactStatPriority("downCount", 2)).toBeLessThan(getCompactStatPriority("totalMonitors", 10));
expect(getCompactStatPriority("pausedCount", 1)).toBeLessThan(getCompactStatPriority("upCount", 8));
});
it("moves zero-value problem states behind healthy totals", () => {
expect(getCompactStatPriority("downCount", 0)).toBeGreaterThan(getCompactStatPriority("upCount", 8));
});
});
+2 -47
View File
@@ -79,25 +79,6 @@ const heroVariantByRing = {
false: classes.heroTextOnly,
} as const;
export function getCompactStatLimit(height: number, showHero: boolean, statCount: number): number {
if (showHero) {
if (height < 180) return 0;
if (height < 260) return Math.min(2, statCount);
if (height < 340) return Math.min(4, statCount);
} else {
if (height < 130) return Math.min(2, statCount);
if (height < 220) return Math.min(4, statCount);
}
return statCount;
}
export function getCompactStatPriority(statKey: keyof typeof statIcons, value: number): number {
if (statKey === "downCount") return value > 0 ? 0 : 4;
if (statKey === "pausedCount") return value > 0 ? 1 : 5;
if (statKey === "totalMonitors") return 2;
return 3;
}
export default function UptimeKumaWidget({
integrationIds,
options,
@@ -158,16 +139,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
.map(([, statKey]) => statKey);
const showHero = isAdvanced || options.showAverageUptime;
const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110);
const prioritizedStatKeys = isAdvanced
? enabledStatKeys
: enabledStatKeys.toSorted(
(left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]),
);
const visibleStatKeys = prioritizedStatKeys.slice(
0,
isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
);
const showRing = isAdvanced || options.showUptimeRing;
const visibleStatKeys = enabledStatKeys;
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
@@ -227,24 +200,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
})}
</div>
)}
{displayMode === "compact" && height >= 300 && combined.downCount > 0 && (
<Stack gap={2} px="sm">
{monitors
.filter((monitor) => monitor.status === "down")
.slice(0, 3)
.map((monitor) => (
<Group key={monitor.key} justify="space-between" wrap="nowrap">
<Text size="xs" truncate>
{monitor.name}
</Text>
<Badge size="xs" color="red">
{t("status.down")} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
</Stack>
)}
</div>
);