Files
homarr/packages/widgets/src/wazuh-timeline/component.tsx
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

167 lines
6.5 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { Badge, Box, Group } from "@mantine/core";
import { AreaChart, BarChart } from "@mantine/charts";
import { IconChartAreaLine } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { WazuhErrorState } from "../wazuh/_shared/error-state";
import { WazuhLegendChip, WazuhPendingState, WazuhWidgetFrame } from "../wazuh/_shared/frame";
import { formatWazuhCount, wazuhSeverityChartColors } from "../wazuh/_shared/severity";
import { useWazuhLinks } from "../wazuh/_shared/use-wazuh-links";
const rangeToRefetchMs = { "1h": 60_000, "24h": 60_000, "7d": 5 * 60_000, "30d": 5 * 60_000 } as const;
const formatInterval = (intervalMs: number, locale: string) => {
const minutes = intervalMs / 60_000;
const [value, unit] =
minutes >= 1440 ? [minutes / 1440, "day"] : minutes >= 60 ? [minutes / 60, "hour"] : [minutes, "minute"];
return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "short" }).format(value);
};
const stackOrder = ["low", "medium", "high", "critical"] as const;
const formatTick = (timestamp: number, range: "1h" | "24h" | "7d" | "30d", locale: string, detailed: boolean) => {
const date = new Date(timestamp);
if (range === "1h" || (range === "24h" && !detailed)) {
return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
}
if (range === "24h") {
return date.toLocaleString(locale, { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
if (range === "7d") {
return detailed
? date.toLocaleString(locale, { weekday: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
: date.toLocaleDateString(locale, { weekday: "short", day: "numeric" });
}
return detailed
? date.toLocaleString(locale, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
: date.toLocaleDateString(locale, { day: "numeric", month: "short" });
};
export default function WazuhTimelineWidget({
integrationIds,
options,
width,
height,
}: WidgetComponentProps<"wazuhTimeline">) {
const t = useI18n("widget.wazuhTimeline");
const tShared = useI18n("widget.wazuh");
const locale = useCurrentIntlLocale();
const integrationId = integrationIds[0] ?? "";
const query = clientApi.widget.wazuh.getTimeline.useQuery(
{ integrationId, range: options.range, interval: options.interval, minLevel: options.minLevel },
{ staleTime: 60_000, refetchInterval: rangeToRefetchMs[options.range] },
);
const result = getUsableWidgetQueryData(query);
const links = useWazuhLinks(result?.dashboardUrl);
const chartData = useMemo(
() =>
(result?.data?.points ?? []).map((point) => ({
label: formatTick(point.timestamp, options.range, locale, true),
tick: formatTick(point.timestamp, options.range, locale, false),
...point,
})),
[result?.data?.points, options.range, locale],
);
const compact = height < 180 || width < 260;
const title = t("title", { range: tShared(`range.${options.range}`) });
if (isInitialWidgetQueryPending(query)) {
return (
<WazuhWidgetFrame icon={IconChartAreaLine} title={title} compact={compact}>
<WazuhPendingState />
</WazuhWidgetFrame>
);
}
if (!result) return <WidgetEmptyState />;
if (result.error) return <WazuhErrorState error={result.error} compact={height < 160} />;
const timeline = result.data;
// Empty severities would only draw a flat line on top of the stack; keep them in the legend only.
const activeSeverities = stackOrder.filter((severity) => timeline.totals[severity] > 0);
const series = (activeSeverities.length > 0 ? activeSeverities : stackOrder).map((severity) => ({
name: severity,
label: tShared(`severity.${severity}`),
color: wazuhSeverityChartColors[severity],
}));
const showLegend = options.showLegend && height >= 150;
const showAxis = height >= 130 && width >= 220;
const sharedProps = {
h: "100%",
data: chartData,
dataKey: "tick",
series,
type: "stacked" as const,
withLegend: false,
withXAxis: showAxis,
withYAxis: showAxis && width >= 300,
gridAxis: "y" as const,
strokeDasharray: "3 3",
tickLine: "none" as const,
yAxisProps: {
width: 44,
scale: options.yScale,
domain: [0, "auto"] as [number, string],
allowDataOverflow: false,
tickFormatter: (value: number) => formatWazuhCount(value, locale),
},
xAxisProps: { minTickGap: 24, interval: "preserveStartEnd" as const },
valueFormatter: (value: number) => value.toLocaleString(locale),
tooltipAnimationDuration: 120,
tooltipProps: {
labelFormatter: (_: unknown, payload: readonly { payload?: { label?: string } }[]) =>
payload[0]?.payload?.label ?? "",
},
};
return (
<WazuhWidgetFrame
icon={IconChartAreaLine}
title={title}
href={links?.events({ from: `now-${options.range}` })}
compact={compact}
headerRight={
<Badge size="sm" variant="light" color="gray" radius="sm">
{t("total", { count: formatWazuhCount(timeline.totals.total, locale) })}
</Badge>
}
>
<Box h="100%" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{showLegend && (
<Group gap="sm" wrap="wrap" style={{ rowGap: 2 }}>
{stackOrder.toReversed().map((severity) => (
<WazuhLegendChip
key={severity}
color={wazuhSeverityChartColors[severity]}
label={tShared(`severity.${severity}`)}
value={formatWazuhCount(timeline.totals[severity], locale)}
/>
))}
{width >= 420 && (
<Box ml="auto" c="dimmed" fz={11}>
{t("interval", { interval: formatInterval(timeline.intervalMs, locale) })}
</Box>
)}
</Group>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
{options.chartType === "bar" ? (
<BarChart {...sharedProps} barProps={{ radius: 1 }} maxBarWidth={18} />
) : (
<AreaChart {...sharedProps} curveType="monotone" withDots={false} fillOpacity={0.55} strokeWidth={1.25} />
)}
</Box>
</Box>
</WazuhWidgetFrame>
);
}