fix(widgets): address review feedback
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user