fix(widgets): address review feedback

This commit is contained in:
ajnart committed 2026-08-27 09:18:27 +02:00
1 parent 1030a21f4e
commit bba20e4561
13 files changed
+66 -88

No files matched your search

@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts, descriptions, and history visible alongside triggered alerts. Alerts are updated in real time via WebSocket subscriptions.
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
### Screenshots
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
### Screenshots
+7 -1
View File
@@ -3179,7 +3179,8 @@
"disabled": "Disabled"
},
"error": {
"internalServerError": "Failed to fetch indexers status"
"internalServerError": "Failed to fetch indexers status",
"testAll": "The indexer test failed. Try again."
}
},
"healthMonitoring": {
@@ -4249,6 +4250,7 @@
"failed": "Failed",
"completed": "Completed"
},
"unknown": "Unknown",
"toBeDetermined": "TBD"
},
"mediaRequests-requestStats": {
@@ -4961,6 +4963,10 @@
"empty": "No alerts configured",
"history": "History",
"resolved": "Resolved",
"description": {
"statusDuration": "Down for {minutes} min",
"threshold": "Exceeds {value} over {minutes} min"
},
"status": {
"triggered": "Triggered",
"ok": "OK"
@@ -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");
});
});
@@ -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 => {
@@ -252,15 +252,14 @@ 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, 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("description.threshold", { value: `${value}${suffix}`, minutes: min });
if (name === "Status") {
description = t("description.statusDuration", { minutes: min });
}
const accentColor = triggered ? "red" : "green";
const borderColor = triggered ? "red" : "gray";
const rowStyle = isAdvanced
@@ -29,6 +29,6 @@
.statItem,
.statItemDense,
.statItemCompact {
border-color: CanvasText;
border: 0.0625rem solid CanvasText;
}
}
@@ -23,6 +23,7 @@ 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";
@@ -62,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));
@@ -189,7 +189,7 @@ const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract,
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
{(request.requestedBy?.displayName ?? "") || t("unknown")}
</Anchor>
</Group>
</Group>
@@ -207,7 +207,7 @@ const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract,
lineClamp={1}
style={{ minWidth: 0 }}
>
{request.name || "unknown"}
{request.name || t("unknown")}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
@@ -59,6 +59,12 @@ describe("network controller status layout", () => {
expect(
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
).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 });
@@ -63,9 +63,23 @@ export default function NetworkControllerSummaryWidget({
<IntegrationErrorIndicator results={indicatorResults} />
</Group>
);
const firstSummary = summaries[0];
if (isPending || !firstSummary) {
return (
<Stack h="100%" gap={0}>
{queryIndicators}
<Center p="sm" style={{ flex: 1 }}>
<Text c="dimmed" size="sm" ta="center">
{isPending ? tCommon("action.loading") : tWidgetCommon("integrationDisconnected")}
</Text>
</Center>
</Stack>
);
}
if (!isAdvanced) {
const summary = summaries[0]?.summary;
const summary = firstSummary.summary;
return (
<Box h="100%" p="sm" pos="relative">
@@ -77,8 +91,8 @@ export default function NetworkControllerSummaryWidget({
<List.Item
icon={
<StatusIcon
status={summary?.wanStatus ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.wanStatus)]}
status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
style={iconSizes.xl}
/>
}
@@ -88,8 +102,8 @@ export default function NetworkControllerSummaryWidget({
<List.Item
icon={
<StatusIcon
status={summary?.www.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.www.status)]}
status={summary.www.status}
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
style={iconSizes.xl}
/>
}
@@ -97,15 +111,15 @@ export default function NetworkControllerSummaryWidget({
<Text>
WWW
<Text c="dimmed" size="md" ms="xs" span>
{summary?.www.latency ?? 0}ms
{summary.www.latency}ms
</Text>
</Text>
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary?.wifi.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.wifi.status)]}
status={summary.wifi.status}
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
style={iconSizes.xl}
/>
}
@@ -115,8 +129,8 @@ export default function NetworkControllerSummaryWidget({
<List.Item
icon={
<StatusIcon
status={summary?.vpn.status ?? "disabled"}
label={statusLabels[getBinaryStatusKey(summary?.vpn.status)]}
status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
style={iconSizes.xl}
/>
}
@@ -124,7 +138,7 @@ export default function NetworkControllerSummaryWidget({
<Text>
{t("card.vpn.label")}
<Text c="dimmed" size="md" ms="xs" span>
{t("card.vpn.countConnected", { count: summary?.vpn.users ?? 0 })}
{t("card.vpn.countConnected", { count: summary.vpn.users })}
</Text>
</Text>
</List.Item>
@@ -134,19 +148,6 @@ export default function NetworkControllerSummaryWidget({
);
}
if (isPending || summaries.length === 0) {
return (
<Stack h="100%" gap={0}>
{queryIndicators}
<Center p="sm" style={{ flex: 1 }}>
<Text c="dimmed" size="sm" ta="center">
{isPending ? tCommon("action.loading") : tWidgetCommon("integrationDisconnected")}
</Text>
</Center>
</Stack>
);
}
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } 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";
@@ -113,7 +113,7 @@ const SystemDiskCard = ({
showTemperature,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
const scheme = useMantineColorScheme();
const colorScheme = useComputedColorScheme("light");
const t = useI18n("widget.systemDisks");
const valueRef = useRef<HTMLParagraphElement>(null);
const [valueFits, setValueFits] = useState(true);
@@ -150,7 +150,7 @@ const SystemDiskCard = ({
.join(" · ");
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const legacyBackground = scheme.colorScheme === "dark" ? "dark.7" : "gray.1";
const 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";
+7 -1
View File
@@ -167,7 +167,13 @@ function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; den
</Group>
{entry.platform && (
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
<Text
size={dense ? "xs" : "sm"}
c={entry.platform.hasChanged ? "red" : undefined}
maw="35%"
truncate="end"
style={{ flexShrink: 1, minWidth: 0 }}
>
{entry.platform.name}
</Text>
)}