Files
homarr/packages/widgets/src/beszel-alerts/component.tsx
T

298 lines
11 KiB
TypeScript

"use client";
import { useMemo } from "react";
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";
import relativeTime from "dayjs/plugin/relativeTime";
import type { LucideIcon } from "lucide-react";
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { getBeszelAlertsQueryInput } from "./display";
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
Memory: MemoryStick,
Disk: HardDrive,
Bandwidth: Network,
Temperature: Thermometer,
"Load Average": Activity,
LoadAvg1: Activity,
LoadAvg5: Activity,
LoadAvg15: Activity,
Status: Server,
GPU: Monitor,
Battery: Battery,
};
dayjs.extend(relativeTime);
export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useI18n("widget.beszelAlerts");
const isAdvanced = displayMode === "advanced";
const alertsInput = useMemo(
() =>
getBeszelAlertsQueryInput(
integrationIds,
{ showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
isAdvanced,
),
[integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced],
);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const runtimeQueries = useMemo(
() => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")],
[alertsInput],
);
useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries);
const resultData = getUsableWidgetQueryData(alertsQuery);
const results = useMemo(() => resultData ?? [], [resultData]);
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]);
const showIntegrationName = isAdvanced || results.length > 1;
const alerts = useMemo(
() =>
results.flatMap((r) =>
r.alerts.map((a) => ({
...a,
integrationId: r.integrationId,
integrationName: r.integrationName,
key: `${r.integrationId}:${a.id}`,
})),
),
[results],
);
const history = useMemo(() => {
const all = results.flatMap((r) =>
r.history.map((h) => ({
...h,
integrationId: r.integrationId,
integrationName: r.integrationName,
key: `${r.integrationId}:${h.id}`,
})),
);
const sorted = all.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
return isAdvanced ? sorted : sorted.slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems, isAdvanced]);
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showHistory = isAdvanced || options.showHistory;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
</Group>
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
{alerts.length === 0 && (
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size="var(--mantine-font-size-lg)" />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t("empty")}
</Text>
</Stack>
)}
{triggeredAlerts.length > 0 && (
<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" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
isAdvanced={isAdvanced}
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{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" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
isAdvanced={isAdvanced}
/>
))}
</Stack>
)}
{showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system);
const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry.key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
h={24}
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size="var(--mantine-font-size-xs)" opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemLabel}
</Text>
</Stack>
</Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="xs" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
</Group>
);
})}
</Stack>
</>
)}
</Stack>
</ScrollArea>
</Box>
);
}
interface AlertRowProps {
name: string;
value: number;
min: number;
systemName: string;
integrationName?: string;
triggered: boolean;
isAdvanced: boolean;
}
const unitSuffixMap: Record<string, string> = {
CPU: "%",
Memory: "%",
Disk: "%",
GPU: "%",
Battery: "%",
Temperature: "°",
Bandwidth: " MB/s",
"Load Average": "",
LoadAvg1: "",
LoadAvg5: "",
LoadAvg15: "",
Status: "",
};
function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
const t = useI18n("widget.beszelAlerts");
const Icon = alertIconMap[name] ?? Server;
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 (
<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>
);
}