fix: complete widget modernization review

This commit is contained in:
Thomas Camlong committed 2026-08-02 08:05:04 +02:00
1 parent 79ae42f0c3
commit 6f27ede8bf
35 files changed
+621 -265

No files matched your search

@@ -8,6 +8,7 @@ import { formatBytes, formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
@@ -47,9 +48,15 @@ export default function AudioStatsWidget({
const sessions = streamResults.flatMap((result) =>
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
);
const hasStreamFailures = streamResults.some(({ error }) => Boolean(error));
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
return (
<Stack h="100%" gap="lg" p="md">
{hasStreamFailures && (
<Group justify="flex-end">
<IntegrationErrorIndicator results={streamResults} />
</Group>
)}
<div style={{ minHeight: 150 }}>{summary}</div>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{response.kind === "navidrome" ? (
@@ -12,7 +12,7 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -85,7 +85,7 @@ export default function BeszelAlertsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -36,7 +36,7 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
@@ -406,7 +406,7 @@ export default function BeszelSystemGridWidget({
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -436,7 +436,7 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
@@ -16,7 +16,7 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -100,7 +100,7 @@ export default function BeszelSystemStatsWidget({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -136,7 +136,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -40,7 +40,7 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
@@ -426,7 +426,7 @@ export default function BeszelSystemTableWidget({
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -1,25 +0,0 @@
"use client";
import { Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface BeszelIntegrationErrorIndicatorProps {
results: IntegrationResult[];
}
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
const failed = results.filter((r) => "error" in r && r.error);
if (failed.length === 0) return null;
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
return (
<Tooltip label={label} position="left" withArrow>
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
</Tooltip>
);
}
+23 -3
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import { getQueryKey } from "@trpc/react-query";
@@ -15,6 +15,7 @@ import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
@@ -25,7 +26,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -56,11 +57,16 @@ const FetchCalendar = ({
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const failedIntegrations =
data?.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
) ?? [];
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
failedIntegrations={failedIntegrations}
month={month}
setMonth={setMonth}
options={options}
@@ -72,13 +78,22 @@ const FetchCalendar = ({
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const CalendarBase = ({
isEditMode,
events,
failedIntegrations,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -105,6 +120,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
return (
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{failedIntegrations.length > 0 && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
</Group>
)}
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
@@ -0,0 +1,38 @@
"use client";
import { Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface IntegrationErrorIndicatorProps {
results: readonly IntegrationResult[];
}
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
const t = useI18n();
const failed = results.filter((result) => Boolean(result.error));
if (failed.length === 0) return null;
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
const label = `${t("common.error")}: ${names}`;
return (
<Tooltip label={label} position="left" withArrow>
<span style={{ display: "inline-flex" }}>
<IconAlertTriangle
aria-hidden
size={14}
color="var(--mantine-color-orange-6)"
style={{ cursor: "help", flexShrink: 0 }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</span>
</Tooltip>
);
}
+11 -6
View File
@@ -120,6 +120,15 @@ export const getWidgetQueryKeys = (definition: {
const runtimeQueriesStateKey = "__homarrRuntimeQueries";
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
export const setWidgetRuntimeQueries = (
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null> | undefined,
queryKeys: readonly QueryKey[],
@@ -127,12 +136,8 @@ export const setWidgetRuntimeQueries = (
if (!widgetStateRef) return;
const state = widgetStateRef.current ?? {};
state[runtimeQueriesStateKey] = queryKeys.flatMap((queryKey) => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return [];
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return [{ path, input }];
const query = normalizeWidgetQuery(queryKey);
return query ? [query] : [];
});
widgetStateRef.current = state;
};
@@ -41,8 +41,19 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
}
if (props.displayMode === "advanced") {
if (props.width < 900) {
return (
<ScrollArea h="100%">
<Stack gap="sm" p="xs">
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} withScrollArea={false} />
{clusters}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
<SimpleGrid cols={2} spacing="sm" p="xs" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box style={{ minHeight: 0, overflow: "hidden" }}>
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Box>
@@ -55,7 +55,8 @@ export const SystemHealthMonitoring = ({
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"healthMonitoring">) => {
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
@@ -69,7 +70,7 @@ export const SystemHealthMonitoring = ({
if (healthData.length === 0) return <WidgetEmptyState />;
return (
<ScrollArea h="100%">
<ScrollArea h={withScrollArea ? "100%" : undefined}>
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
+6 -1
View File
@@ -74,7 +74,12 @@ export type {
WidgetQueryMatcher,
WidgetQueryMatcherScope,
} from "./definition";
export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition";
export {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
normalizeWidgetQuery,
widgetQueryValueEquals,
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,19 +1,6 @@
"use client";
import {
ActionIcon,
Anchor,
Avatar,
Badge,
Box,
Card,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
@@ -27,6 +14,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
@@ -44,7 +32,7 @@ export default function MediaServerWidget({
.filter(({ id }) => integrationIds.includes(id))
.map(({ id }) => id),
);
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
@@ -53,16 +41,22 @@ export default function MediaServerWidget({
recentDays: options.recentDays,
});
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
return (
<Box className={classes.searchRoot}>
<Stack className={classes.searchRoot} gap={0}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
{failedIntegrations.length > 0 && (
<Group px="sm" pt="xs">
<IntegrationErrorIndicator results={failedIntegrations} />
</Group>
)}
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
@@ -77,7 +71,7 @@ export default function MediaServerWidget({
))}
</Stack>
</ScrollArea>
</Box>
</Stack>
);
}
@@ -100,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
};
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isAdvanced: boolean;
canInteract: boolean;
@@ -22,6 +22,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
@@ -45,7 +46,12 @@ export default function MediaServerWidget({
const board = useRequiredBoard();
if (!requestStats) return <WidgetEmptyState />;
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
if (
requestStats.users.length === 0 &&
requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0
)
throw new NoIntegrationDataError();
const data = [
{
@@ -99,6 +105,9 @@ export default function MediaServerWidget({
return (
<Box className={searchClasses.searchRoot}>
<Box pos="absolute" top="xs" left="xs" style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={requestStats.failedIntegrations} />
</Box>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<Stack
className="mediaRequests-stats-layout"
@@ -25,6 +25,7 @@ import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
@@ -305,6 +306,7 @@ export default function MediaServerWidget({
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
return (
<Stack gap={0} h="100%" display="flex">
@@ -317,6 +319,14 @@ export default function MediaServerWidget({
<Badge variant="light" color="orange">
{t("summary.transcoding", { count: transcodingCount })}
</Badge>
<Group ml="auto">
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && hasFailedIntegrations && (
<Group h={18} px="xs" justify="flex-end">
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<MantineReactTable table={table} />
+52 -39
View File
@@ -146,48 +146,61 @@ function TraefikWidgetContent({
(left, right) =>
resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
);
return (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%" p="md">
<ScrollArea h="100%">{summary}</ScrollArea>
const resourceList = (
<Stack gap="xs">
{combined.failedEndpoints.map((endpoint) => (
<Badge key={endpoint} color="red" variant="light">
{t("failedEndpoint", { endpoint })}
</Badge>
))}
{resources.map((resource, index) => (
<Group
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
justify="space-between"
wrap="nowrap"
p="xs"
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{resource.name}
</Text>
<Text size="xs" c="dimmed">
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
</Text>
</Stack>
<Badge
color={
resource.status === "error"
? "red"
: resource.status === "warning"
? "yellow"
: resource.status === "enabled"
? "green"
: "gray"
}
>
{t(`resourceStatus.${resource.status}`)}
</Badge>
</Group>
))}
</Stack>
);
if (width < 900) {
return (
<ScrollArea h="100%">
<Stack gap="xs">
{combined.failedEndpoints.map((endpoint) => (
<Badge key={endpoint} color="red" variant="light">
{t("failedEndpoint", { endpoint })}
</Badge>
))}
{resources.map((resource, index) => (
<Group
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
justify="space-between"
wrap="nowrap"
p="xs"
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{resource.name}
</Text>
<Text size="xs" c="dimmed">
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
</Text>
</Stack>
<Badge
color={
resource.status === "error"
? "red"
: resource.status === "warning"
? "yellow"
: resource.status === "enabled"
? "green"
: "gray"
}
>
{t(`resourceStatus.${resource.status}`)}
</Badge>
</Group>
))}
<Stack gap="md" p="md">
{summary}
{resourceList}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<ScrollArea h="100%">{summary}</ScrollArea>
<ScrollArea h="100%">{resourceList}</ScrollArea>
</SimpleGrid>
);
}
+2
View File
@@ -14,6 +14,7 @@ export default function UmamiWidget({
options,
integrationIds,
width,
height,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"umami">) {
@@ -75,6 +76,7 @@ export default function UmamiWidget({
viewMode={options.viewMode}
topCount={options.topCount}
width={width}
height={height}
displayMode={displayMode}
/>
);
+42 -26
View File
@@ -1,7 +1,7 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
@@ -24,6 +24,7 @@ interface UmamiContentProps {
viewMode: string;
topCount: number;
width: number;
height: number;
displayMode: "compact" | "advanced";
}
@@ -38,6 +39,7 @@ export function UmamiContent({
viewMode,
topCount,
width,
height,
displayMode,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
@@ -149,6 +151,44 @@ export function UmamiContent({
/>
);
const advancedLists = (
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
);
const advancedContent =
width < 900 ? (
<ScrollArea h="100%">
<Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists}
</Stack>
</ScrollArea>
) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}>
{selectedView}
</Box>
{advancedLists}
</SimpleGrid>
);
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
@@ -213,31 +253,7 @@ export function UmamiContent({
)}
</Group>
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%">
<Box mih={260}>{selectedView}</Box>
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
</SimpleGrid>
) : (
selectedView
)}
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
</Stack>
);
+38 -25
View File
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Box, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -213,33 +213,46 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
if (displayMode === "compact") return summaryContent;
return (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="lg" h="100%" p="md">
{summaryContent}
const monitorList = (
<Stack gap="xs">
{monitors
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
.map((monitor) => (
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
)}
</Stack>
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
{t(`status.${monitor.status}`)} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
</Stack>
);
if (width < 760) {
return (
<ScrollArea h="100%">
<Stack gap="xs">
{monitors
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
.map((monitor) => (
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
)}
</Stack>
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
{t(`status.${monitor.status}`)} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
);
}