fix(widgets): restore compact v1 surfaces (#6710)
This commit is contained in:
1 parent
1b2f1bd720
commit
643ecf3df7
105 files changed
+1603
-1285
No files matched your search
@@ -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>
|
||||
|
||||
@@ -7,16 +7,10 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getCompactStatLayout } from "../common/compact-stat-layout";
|
||||
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,11 +27,49 @@ const statTileClassByCompact = {
|
||||
false: "",
|
||||
} as const;
|
||||
|
||||
const statTileClassByAdvanced = {
|
||||
true: classes.statTileAdvanced,
|
||||
false: "",
|
||||
} as const;
|
||||
|
||||
const rootClassByLayout = {
|
||||
default: "",
|
||||
short: classes.rootShort,
|
||||
narrowShort: classes.rootShort,
|
||||
} as const;
|
||||
|
||||
const gridClassByLayout = {
|
||||
default: "",
|
||||
short: classes.gridShort,
|
||||
narrowShort: classes.gridShort,
|
||||
} as const;
|
||||
|
||||
const statTileClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statTileShort,
|
||||
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
|
||||
} as const;
|
||||
|
||||
const statLabelClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statLabelShort,
|
||||
narrowShort: classes.statLabelShort,
|
||||
} as const;
|
||||
|
||||
const statValueClassByLayout = {
|
||||
default: "",
|
||||
short: "",
|
||||
narrowShort: classes.statValueNarrowShort,
|
||||
} 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;
|
||||
}
|
||||
@@ -47,29 +79,45 @@ 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 iconSize = getIconSize(width, compact);
|
||||
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
|
||||
let defaultGridCols = getGridCols(width, visibleStats.length, compact);
|
||||
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
|
||||
defaultGridCols = 1;
|
||||
}
|
||||
const layout = getCompactStatLayout({
|
||||
width,
|
||||
height,
|
||||
visibleCount: visibleStats.length,
|
||||
compactDisplay: !advanced,
|
||||
defaultColumns: defaultGridCols,
|
||||
defaultIconSize: getIconSize(width, compact),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
|
||||
{visibleStats.length > 0 && (
|
||||
<div
|
||||
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
|
||||
style={{ "--stat-cols": gridCols } as CSSProperties}
|
||||
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
|
||||
style={{ "--stat-cols": layout.columns } as CSSProperties}
|
||||
>
|
||||
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
|
||||
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
|
||||
<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>
|
||||
<div
|
||||
key={optionKey}
|
||||
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
|
||||
>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
|
||||
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
|
||||
{t(statKey as never)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
|
||||
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("caps columns to visible stat count", () => {
|
||||
@@ -37,17 +37,6 @@ describe("getIconSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStatLimit", () => {
|
||||
test("keeps compact metrics legible when height is constrained", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
|
||||
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves every configured metric outside compact mode", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStats", () => {
|
||||
const navidromeStats: NavidromeDashboardData = {
|
||||
artistCount: 100,
|
||||
@@ -117,21 +106,3 @@ describe("getVisibleStats", () => {
|
||||
expect(typeof result[0]?.value).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeVisibleStats", () => {
|
||||
test("puts an active session first only on compact surfaces", () => {
|
||||
const stats = getVisibleStats(
|
||||
"audiobookshelf",
|
||||
{ showLibraryCount: true, showActiveSessions: true },
|
||||
{
|
||||
libraryCount: 3,
|
||||
totalAudiobooks: 150,
|
||||
totalPodcasts: 20,
|
||||
totalListeningTimeSeconds: 86400,
|
||||
activeSessions: 2,
|
||||
},
|
||||
);
|
||||
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
|
||||
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
}
|
||||
|
||||
.rootShort {
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -29,6 +34,10 @@
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
}
|
||||
|
||||
.gridShort {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -40,16 +49,31 @@
|
||||
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);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.statTileShort {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.statTileNarrowShort {
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
@@ -57,7 +81,12 @@
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 11px);
|
||||
flex-shrink: 0;
|
||||
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 +97,32 @@
|
||||
}
|
||||
|
||||
.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))
|
||||
);
|
||||
}
|
||||
|
||||
.statLabelShort {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(13px, 5cqi, 20px);
|
||||
flex-shrink: 0;
|
||||
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 +134,16 @@
|
||||
}
|
||||
|
||||
.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))
|
||||
);
|
||||
}
|
||||
|
||||
.statValueNarrowShort {
|
||||
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function AudioStatsWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
displayMode = "compact",
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
@@ -39,14 +40,22 @@ export default function AudioStatsWidget({
|
||||
const currentStreams = streamResults ?? [];
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= 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}
|
||||
height={responsiveHeight}
|
||||
/>
|
||||
);
|
||||
if (displayMode === "compact") {
|
||||
|
||||
@@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
|
||||
return Math.min(maxCols, Math.max(visibleCount, 1));
|
||||
};
|
||||
|
||||
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
|
||||
if (!compact) return visibleCount;
|
||||
|
||||
const columns = getGridCols(width, visibleCount, true);
|
||||
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
|
||||
return Math.min(visibleCount, columns * rows);
|
||||
};
|
||||
|
||||
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
|
||||
if (!compact) return stats;
|
||||
return stats.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
|
||||
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const fallbackIconSize = { true: 14, false: 16 } as const;
|
||||
|
||||
export const getIconSize = (width: number, compact: boolean): number => {
|
||||
|
||||
@@ -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> = {
|
||||
@@ -268,49 +252,46 @@ const unitSuffixMap: Record<string, string> = {
|
||||
Status: "",
|
||||
};
|
||||
|
||||
function formatAlertDescription(name: string, value: number, min: number): string {
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
if (name === "Status") return `down for ${min} min`;
|
||||
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 t = useI18n("widget.beszelAlerts");
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
const description = formatAlertDescription(name, value, min);
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
let description = t("alertDescription.threshold", { value: `${value}${suffix}`, minutes: min });
|
||||
if (name === "Status") {
|
||||
description = t("alertDescription.statusDuration", { minutes: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const SHORT_HEIGHT_BREAKPOINT = 120;
|
||||
const NARROW_SHORT_WIDTH_BREAKPOINT = 120;
|
||||
const NARROW_SHORT_ICON_SIZE = 12;
|
||||
const SHORT_GRID_HORIZONTAL_INSET = 8;
|
||||
const SHORT_GRID_GAP = 2;
|
||||
const SHORT_ICON_WIDTH_RATIO = 0.44;
|
||||
const SHORT_ICON_SIZE_CAP = 40;
|
||||
|
||||
export type CompactStatLayoutState = "default" | "short" | "narrowShort";
|
||||
|
||||
interface CompactStatLayoutInput {
|
||||
width: number;
|
||||
height: number;
|
||||
visibleCount: number;
|
||||
/** Compact board display mode, independent of widget-specific compact styling options. */
|
||||
compactDisplay: boolean;
|
||||
defaultColumns: number;
|
||||
defaultIconSize: number;
|
||||
}
|
||||
|
||||
interface CompactStatLayout {
|
||||
state: CompactStatLayoutState;
|
||||
columns: number;
|
||||
iconSize: number;
|
||||
}
|
||||
|
||||
export function getCompactStatLayout({
|
||||
width,
|
||||
height,
|
||||
visibleCount,
|
||||
compactDisplay,
|
||||
defaultColumns,
|
||||
defaultIconSize,
|
||||
}: CompactStatLayoutInput): CompactStatLayout {
|
||||
if (!compactDisplay) {
|
||||
return {
|
||||
state: "default",
|
||||
columns: defaultColumns,
|
||||
iconSize: defaultIconSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (width < NARROW_SHORT_WIDTH_BREAKPOINT) {
|
||||
return {
|
||||
state: "narrowShort",
|
||||
columns: 1,
|
||||
iconSize: NARROW_SHORT_ICON_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
if (height >= SHORT_HEIGHT_BREAKPOINT) {
|
||||
return {
|
||||
state: "default",
|
||||
columns: defaultColumns,
|
||||
iconSize: defaultIconSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "short",
|
||||
columns: Math.max(visibleCount, 1),
|
||||
iconSize: getShortIconSize(width, visibleCount, defaultIconSize),
|
||||
};
|
||||
}
|
||||
|
||||
function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number {
|
||||
const statCount = Math.max(visibleCount, 1);
|
||||
const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP;
|
||||
const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0);
|
||||
const availableWidthPerStat = availableWidth / statCount;
|
||||
const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO);
|
||||
const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP);
|
||||
return Math.max(defaultIconSize, cappedIconSize);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.statItem,
|
||||
.statItemDense {
|
||||
border-color: CanvasText;
|
||||
.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,
|
||||
.statItemCompact {
|
||||
border: 0.0625rem solid 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,
|
||||
@@ -22,7 +23,9 @@ import combineClasses from "clsx";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeActions } from "../runtime-hooks";
|
||||
@@ -60,6 +63,11 @@ export default function IndexerManagerWidget({
|
||||
isPending,
|
||||
error: testAllError,
|
||||
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: tCommon("error"),
|
||||
message: t("error.testAll"),
|
||||
}),
|
||||
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
|
||||
});
|
||||
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
|
||||
@@ -67,8 +75,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 +95,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 +132,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 +174,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 +192,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 +214,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 +226,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 +244,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 ?? "") || t("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 || t("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 });
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -48,11 +49,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 +170,6 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
isEditMode,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaServer">) {
|
||||
@@ -411,61 +409,76 @@ 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)" : undefined}
|
||||
style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)}
|
||||
/>
|
||||
<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";
|
||||
@@ -13,8 +12,7 @@ import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
const neutralBorderColor =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
const neutralBorderColor = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
export default function NetworkControllerNetworkStatusWidget({
|
||||
options,
|
||||
@@ -22,17 +20,18 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
displayMode,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||
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");
|
||||
@@ -42,14 +41,55 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
|
||||
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
|
||||
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
const layout = getNetworkControllerStatusLayout({
|
||||
width: responsiveWidth,
|
||||
height: responsiveHeight,
|
||||
displayMode,
|
||||
content: options.content,
|
||||
});
|
||||
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={layout.padding} pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
{options.content === "wifi" ? (
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
inline={layout.inlineStats}
|
||||
/>
|
||||
) : (
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
inline={layout.inlineStats}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (summaryQuery.isPending || summaries.length === 0) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
@@ -63,92 +103,40 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe("network controller status layout", () => {
|
||||
{
|
||||
name: "minimum compact",
|
||||
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
expected: { padding: 4, columns: 1, showWifi: true, showWired: false, horizontalStats: true },
|
||||
},
|
||||
{
|
||||
name: "short compact",
|
||||
@@ -58,7 +58,13 @@ describe("network controller status layout", () => {
|
||||
).toMatchObject({ padding: "sm", horizontalStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "xs", horizontalStats: false });
|
||||
).toMatchObject({ padding: 4, horizontalStats: true });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 80, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ inlineStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 79, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ inlineStats: true });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }),
|
||||
).toMatchObject({ sourceColumns: 2 });
|
||||
|
||||
@@ -11,7 +11,7 @@ interface NetworkControllerStatusLayoutInput {
|
||||
}
|
||||
|
||||
export interface NetworkControllerStatusLayout {
|
||||
padding: "xs" | "sm" | "md";
|
||||
padding: 4 | "xs" | "sm" | "md";
|
||||
columns: 1 | 2;
|
||||
sourceColumns: 1 | 2;
|
||||
showWifi: boolean;
|
||||
@@ -20,6 +20,7 @@ export interface NetworkControllerStatusLayout {
|
||||
withBorder: boolean;
|
||||
compact: boolean;
|
||||
horizontalStats: boolean;
|
||||
inlineStats: boolean;
|
||||
}
|
||||
|
||||
export const getNetworkControllerStatusLayout = ({
|
||||
@@ -29,16 +30,18 @@ export const getNetworkControllerStatusLayout = ({
|
||||
content,
|
||||
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const compact = !isAdvanced && (width < 240 || height < 180);
|
||||
|
||||
return {
|
||||
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
|
||||
padding: isAdvanced ? "md" : height < 120 ? 4 : "sm",
|
||||
columns: isAdvanced && width >= 560 ? 2 : 1,
|
||||
sourceColumns: isAdvanced && width >= 960 ? 2 : 1,
|
||||
showWifi: isAdvanced || content === "wifi",
|
||||
showWired: isAdvanced || content === "wired",
|
||||
cardPadding: isAdvanced ? "md" : 0,
|
||||
withBorder: isAdvanced,
|
||||
compact: !isAdvanced,
|
||||
horizontalStats: !isAdvanced && height < 150 && width >= 200,
|
||||
compact,
|
||||
horizontalStats: compact && height < 150,
|
||||
inlineStats: compact && height < 80,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,44 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
export const StatRow = ({
|
||||
label,
|
||||
value,
|
||||
advanced = false,
|
||||
compact = false,
|
||||
inline = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
if (!advanced && compact && inline) {
|
||||
return (
|
||||
<Group gap={2} align="baseline" justify="center" wrap="nowrap" miw={0}>
|
||||
<Text size="xl" fw={800} lh={1} style={{ flexShrink: 0 }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!advanced) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size={compact ? "xl" : "2xl"} fw={compact ? 800 : 900} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size={compact ? "sm" : "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,50 @@ 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,
|
||||
inline = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
if (!advanced) {
|
||||
const iconSize = horizontal ? 20 : 24;
|
||||
let titleMargin: 0 | "xs" | "md" = "md";
|
||||
if (compact) titleMargin = "xs";
|
||||
if (horizontal) titleMargin = 0;
|
||||
let statsSpacing: number | "xs" | "lg" = "lg";
|
||||
if (compact) statsSpacing = "xs";
|
||||
if (horizontal) statsSpacing = 4;
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
|
||||
<IconWifi style={zoomCompensatedSize(iconSize)} />
|
||||
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
@@ -29,8 +57,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,50 @@ 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,
|
||||
inline = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
if (!advanced) {
|
||||
const iconSize = horizontal ? 20 : 24;
|
||||
let titleMargin: 0 | "xs" | "md" = "md";
|
||||
if (compact) titleMargin = "xs";
|
||||
if (horizontal) titleMargin = 0;
|
||||
let statsSpacing: number | "xs" | "lg" = "lg";
|
||||
if (compact) statsSpacing = "xs";
|
||||
if (horizontal) statsSpacing = 4;
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
|
||||
<IconNetwork style={zoomCompensatedSize(iconSize)} />
|
||||
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
@@ -29,8 +57,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,17 +54,18 @@ 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>
|
||||
);
|
||||
const firstSummary = summaries[0];
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
if (isPending || !firstSummary) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
{queryIndicators}
|
||||
@@ -74,190 +78,152 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
const controllerColumns = width >= 960 ? 2 : 1;
|
||||
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
|
||||
if (!isAdvanced) {
|
||||
const summary = firstSummary.summary;
|
||||
|
||||
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 }}
|
||||
<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}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{t("card.wan")}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="md" ms="xs" span>
|
||||
{summary.www.latency}ms
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("card.wifi")}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
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 })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 +246,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>
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
padding: clamp(6px, 3cqi, 12px);
|
||||
gap: clamp(4px, 2cqi, 8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rootShort {
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,8 +21,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 {
|
||||
@@ -81,13 +85,18 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
gap: clamp(4px, 2cqi, 8px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.gridShort {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,22 +104,37 @@
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
gap: clamp(2px, 1.5cqi, 4px);
|
||||
padding: clamp(4px, 2cqi, 8px);
|
||||
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;
|
||||
}
|
||||
|
||||
.statTileShort {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.statTileNarrowShort {
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
@@ -120,12 +144,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statLabelShort {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
flex-shrink: 0;
|
||||
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-color-text);
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statValueNarrowShort {
|
||||
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getCompactStatLayout } from "../common/compact-stat-layout";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
@@ -32,11 +33,41 @@ const statIcons = {
|
||||
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 380, cols: 3 },
|
||||
{ minWidth: 300, cols: 3 },
|
||||
{ minWidth: 220, cols: 2 },
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const rootClassByLayout = {
|
||||
default: "",
|
||||
short: classes.rootShort,
|
||||
narrowShort: classes.rootShort,
|
||||
} as const;
|
||||
|
||||
const gridClassByLayout = {
|
||||
default: "",
|
||||
short: classes.gridShort,
|
||||
narrowShort: classes.gridShort,
|
||||
} as const;
|
||||
|
||||
const statTileClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statTileShort,
|
||||
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
|
||||
} as const;
|
||||
|
||||
const statLabelClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statLabelShort,
|
||||
narrowShort: classes.statLabelShort,
|
||||
} as const;
|
||||
|
||||
const statValueClassByLayout = {
|
||||
default: "",
|
||||
short: "",
|
||||
narrowShort: classes.statValueNarrowShort,
|
||||
} as const;
|
||||
|
||||
const ringSizeByWidth = [
|
||||
{ minWidth: 400, size: 88 },
|
||||
{ minWidth: 320, size: 72 },
|
||||
@@ -76,6 +107,8 @@ export default function PaperlessNgxWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useI18n("widget.paperlessNgx");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -116,19 +149,31 @@ 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;
|
||||
let responsiveHeight = height;
|
||||
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
const advanced = displayMode === "advanced";
|
||||
const layout = getCompactStatLayout({
|
||||
width: responsiveWidth,
|
||||
height: responsiveHeight,
|
||||
visibleCount: visibleStatKeys.length,
|
||||
compactDisplay: !advanced,
|
||||
defaultColumns: getGridCols(responsiveWidth),
|
||||
defaultIconSize: getIconSize(responsiveWidth),
|
||||
});
|
||||
const ringSize = getRingSize(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 = {
|
||||
@@ -162,7 +207,7 @@ export default function PaperlessNgxWidget({
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={`${classes.root} ${rootClassByLayout[layout.state]}`}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
{visibleHeroParts.map(([partKey]) => (
|
||||
@@ -180,14 +225,19 @@ export default function PaperlessNgxWidget({
|
||||
)}
|
||||
|
||||
{visibleStatKeys.length > 0 && (
|
||||
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
<div
|
||||
className={`${classes.grid} ${gridClassByLayout[layout.state]}`}
|
||||
style={{ "--stat-cols": layout.columns } as CSSProperties}
|
||||
>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
return (
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={classes.statValue}>{statValues[statKey]}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
<div key={statKey} className={`${classes.statTile} ${statTileClassByLayout[layout.state]}`}>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>
|
||||
{statValues[statKey]}
|
||||
</span>
|
||||
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>{t(statKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -205,9 +255,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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, useComputedColorScheme } 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 colorScheme = useComputedColorScheme("light");
|
||||
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 = 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>
|
||||
))}
|
||||
|
||||
@@ -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,8 +166,14 @@ function DepartureRow({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showPlatform && entry.platform && (
|
||||
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
|
||||
{entry.platform && (
|
||||
<Text
|
||||
size={dense ? "xs" : "sm"}
|
||||
c={entry.platform.hasChanged ? "red" : undefined}
|
||||
maw="35%"
|
||||
truncate="end"
|
||||
style={{ flexShrink: 1, minWidth: 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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user