Compare commits

...
9 changed files with 114 additions and 104 deletions

No files matched your search

@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build(); .build();
const baseLayout = board.layouts.find((layout) => layout.role === "base"); const baseLayout = board.layouts.find((layout) => layout.role === "base");
const result = createItemCallback({ kind: "mediaMissing" })(board); const result = createItemCallback({ kind: "downloads" })(board);
const createdItem = result.items.at(0); const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3); expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board); const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }), expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
]); ]);
}); });
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id, layoutId: layout.id,
sectionId: "canvas", sectionId: "canvas",
width: 1, width: 1,
height: 3, height: 2,
xOffset: 0, xOffset: 0,
yOffset: 1, yOffset: 1,
}), }),
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within { .board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: var(--homarr-z-index-widget-focus); z-index: calc(var(--homarr-z-index-board-header) - 1);
} }
.board-grid-entry[data-grid-preview="true"] { .board-grid-entry[data-grid-preview="true"] {
+1 -1
View File
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
expect(mediaMissing).toBeDefined(); expect(mediaMissing).toBeDefined();
expect( expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }), await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 4, height: 3 }); ).toMatchObject({ width: 2, height: 2 });
}); });
it("requires an exact board id when more than one board exists", async () => { it("requires an exact board id when more than one board exists", async () => {
@@ -1,11 +1,16 @@
.beszelStatsControls { .beszelStatsControls {
position: absolute;
z-index: 2;
top: var(--mantine-spacing-sm);
right: var(--mantine-spacing-sm);
left: var(--mantine-spacing-sm);
opacity: 0; opacity: 0;
transition: opacity 150ms ease-out; transition: opacity 150ms ease-out;
pointer-events: none; pointer-events: none;
} }
.beszelStatsContainer:hover .beszelStatsControls, .beszelStatsRoot:hover .beszelStatsControls,
.beszelStatsContainer:focus-within .beszelStatsControls { .beszelStatsRoot:focus-within .beszelStatsControls {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -23,9 +23,8 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const CONTROLS_ROW_HEIGHT = 36;
const CONTROLS_ROW_GAP = 16;
const STACK_PADDING = 24; const STACK_PADDING = 24;
const SCROLL_FIT_BUFFER = 2;
export default function BeszelSystemStatsWidget({ export default function BeszelSystemStatsWidget({
options, options,
@@ -33,6 +32,7 @@ export default function BeszelSystemStatsWidget({
isEditMode, isEditMode,
width, width,
height, height,
displayScale = 1,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
@@ -71,6 +71,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") }, { value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") }, { value: "30d", label: t("period.thirtyDays") },
]; ];
let responsiveWidth = width;
let responsiveHeight = height;
if (Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
useWidgetRuntimeQueries( useWidgetRuntimeQueries(
widgetRuntimeRef, widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m" selectedSystem && options.timePeriod !== "1m"
@@ -185,86 +191,83 @@ export default function BeszelSystemStatsWidget({
} }
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative" className={classes.beszelStatsRoot}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}> <Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}> <Group gap={0}>
<IntegrationErrorIndicator results={systemsResult} /> <IntegrationErrorIndicator results={systemsResult} />
</Group> </Group>
</Box> </Box>
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer style={iconSizes.sm} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{responsiveWidth >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
<ScrollArea <ScrollArea
h="100%" h="100%"
className={classes.beszelStatsContainer} className={classes.beszelStatsContainer}
style={{ pointerEvents: isEditMode ? "none" : undefined }} style={{ pointerEvents: isEditMode ? "none" : undefined }}
> >
<Stack gap="md" p="sm"> <Stack p="sm">
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer style={iconSizes.sm} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{width >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
<BeszelStatsView <BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []} integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""} systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod} timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1} columns={responsiveWidth > 600 ? 2 : 1}
availableHeight={Math.max( availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)}
0,
height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP),
)}
visibility={{ visibility={{
cpu: options.showCpu, cpu: options.showCpu,
memory: options.showMemory, memory: options.showMemory,
@@ -101,12 +101,12 @@ interface BeszelChartPanelProps {
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => ( export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
<Stack gap={4} style={panelStyle}> <Stack gap={4} style={panelStyle}>
<Group gap="xs"> <Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600}> <Text size="sm" fw={600} style={{ flexShrink: 0 }}>
{title} {title}
</Text> </Text>
{subtitle && ( {subtitle && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed" truncate style={{ minWidth: 0 }}>
{subtitle} {subtitle}
</Text> </Text>
)} )}
@@ -35,13 +35,13 @@ const tooltips = {
bytesTotal: makeTooltipProps(formatStorageBytes, true), bytesTotal: makeTooltipProps(formatStorageBytes, true),
}; };
const ChartSkeleton = () => ( const ChartSkeleton = ({ height }: { height: number }) => (
<Stack gap={4} style={{ minWidth: 0 }}> <Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs"> <Group gap="xs">
<Skeleton h={14} w={72} radius="sm" /> <Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" /> <Skeleton h={10} w={110} radius="sm" />
</Group> </Group>
<Skeleton h={CHART_HEIGHT} radius="sm" /> <Skeleton h={height} radius="sm" />
</Stack> </Stack>
); );
@@ -67,7 +67,7 @@ interface BeszelStatsViewProps {
} }
const MIN_CHART_HEIGHT = 100; const MIN_CHART_HEIGHT = 100;
const PANEL_ROW_OVERHEAD = 28; const PANEL_ROW_OVERHEAD = 25;
const GRID_ROW_GAP = 16; const GRID_ROW_GAP = 16;
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => { const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
@@ -238,10 +238,13 @@ export function BeszelStatsView({
if (!activeStats) { if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length; const visibleChartCount = Object.values(visibility).filter(Boolean).length;
const effectiveColumns = Math.min(columns, Math.max(1, visibleChartCount)) as 1 | 2;
const rowCount = Math.ceil(visibleChartCount / effectiveColumns);
const chartHeight = computeChartHeight(availableHeight, rowCount);
return ( return (
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}> <SimpleGrid cols={effectiveColumns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => ( {Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} /> <ChartSkeleton key={index} height={chartHeight} />
))} ))}
</SimpleGrid> </SimpleGrid>
); );
@@ -4,8 +4,8 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: clamp(6px, 3cqi, 14px); padding: clamp(6px, 3cqmin, 14px);
gap: clamp(4px, 2cqi, 10px); gap: clamp(4px, 2cqmin, 10px);
overflow: hidden; overflow: hidden;
} }
@@ -13,11 +13,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: clamp(8px, 3cqi, 18px); gap: clamp(8px, 3cqmin, 18px);
flex-shrink: 0; flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px); padding-bottom: clamp(4px, 2cqmin, 10px);
border-bottom: 1px solid border-bottom: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
.heroExpanded { .heroExpanded {
@@ -37,25 +36,25 @@
} }
.heroTextOnly .heroValue { .heroTextOnly .heroValue {
font-size: clamp(22px, 10cqi, 36px); font-size: clamp(22px, 10cqmin, 36px);
} }
.heroText { .heroText {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: clamp(2px, 1cqi, 4px); gap: clamp(2px, 1cqmin, 4px);
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.heroLabel { .heroLabel {
font-size: clamp(9px, 2.8cqi, 13px); font-size: clamp(9px, 2.8cqmin, 13px);
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
line-height: 1.2; line-height: 1.2;
} }
.heroValue { .heroValue {
font-size: clamp(18px, 8cqi, 32px); font-size: clamp(18px, 8cqmin, 32px);
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
} }
@@ -68,7 +67,7 @@
display: grid; display: grid;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
gap: clamp(4px, 2cqi, 10px); gap: clamp(4px, 2cqmin, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
align-content: stretch; align-content: stretch;
@@ -81,11 +80,10 @@
justify-content: center; justify-content: center;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
gap: clamp(2px, 1.5cqi, 5px); gap: clamp(2px, 1.5cqmin, 5px);
padding: clamp(4px, 2.5cqi, 10px); padding: clamp(4px, 2.5cqmin, 10px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
border: 1px solid border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent; background-color: transparent;
overflow: hidden; overflow: hidden;
} }
@@ -95,14 +93,14 @@
} }
.statValue { .statValue {
font-size: clamp(14px, 5.5cqi, 24px); font-size: clamp(14px, 5.5cqmin, 24px);
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
text-align: center; text-align: center;
} }
.statLabel { .statLabel {
font-size: clamp(8px, 2.5cqi, 12px); font-size: clamp(8px, 2.5cqmin, 12px);
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
line-height: 1.2; line-height: 1.2;
text-align: center; text-align: center;
@@ -119,6 +117,6 @@
justify-content: center; justify-content: center;
min-height: 0; min-height: 0;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 14px); font-size: clamp(11px, 3cqmin, 14px);
text-align: center; text-align: center;
} }
@@ -129,8 +129,9 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
); );
const uptimeValue = clampPercent(combined.averageUptimePercent); const uptimeValue = clampPercent(combined.averageUptimePercent);
const uptimeColor = getUptimeColor(uptimeValue); const uptimeColor = getUptimeColor(uptimeValue);
const ringSize = getRingSize(width); const visualScale = Math.min(width, height);
const iconSize = getIconSize(width); const ringSize = getRingSize(visualScale);
const iconSize = getIconSize(visualScale);
const gridCols = getGridCols(width); const gridCols = getGridCols(width);
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
@@ -247,7 +248,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
</Group> </Group>
<ScrollArea h="100%"> <ScrollArea h="100%">
<Stack gap="lg" p="md"> <Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box> <Box h={Math.min(420, Math.max(180, Math.floor(height * 0.55)))}>{summaryContent}</Box>
{monitorList} {monitorList}
</Stack> </Stack>
</ScrollArea> </ScrollArea>