feat(ee): SIEM (#2471)

This commit is contained in:
Philip Okugbe
2026-09-04 14:53:14 +01:00
committed by GitHub
parent 5b85464561
commit 0d69d48c52
43 changed files with 2612 additions and 122 deletions
@@ -0,0 +1,44 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
interface DeleteDestinationModalProps {
opened: boolean;
onClose: () => void;
destination: ISiemDestination | null;
}
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
const { t } = useTranslation();
const deleteMutation = useDeleteSiemDestinationMutation();
const handleDelete = async () => {
if (!destination) return;
await deleteMutation.mutateAsync({ destinationId: destination.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Delete destination")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to delete the destination")}{" "}
<strong>{destination?.name}</strong>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
{t("Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Collapse,
Group,
Modal,
PasswordInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
import {
useCreateSiemDestinationMutation,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import {
DestinationFormValues,
initialValues,
toPayload,
validateForm,
} from "@/ee/siem/lib/destination-form";
import { DESTINATION_TYPE_LABELS } from "./destination-table";
interface DestinationFormModalProps {
opened: boolean;
onClose: () => void;
destination?: ISiemDestination | null;
}
function connectionKey(values: DestinationFormValues): string {
const { type, config, secrets } = toPayload(values);
return JSON.stringify({ type, config, secrets });
}
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
const { t } = useTranslation();
const isEdit = Boolean(destination);
const hasSecrets = destination?.hasSecrets ?? {};
const [advancedOpen, setAdvancedOpen] = useState(false);
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
result: null,
testedPayloadKey: null,
});
const createMutation = useCreateSiemDestinationMutation();
const updateMutation = useUpdateSiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const form = useForm<DestinationFormValues>({
initialValues: initialValues(destination),
validate: (values) => validateForm(values, hasSecrets),
});
useEffect(() => {
if (opened) {
form.setValues(initialValues(destination));
form.resetDirty();
// eslint-disable-next-line react-hooks/set-state-in-effect
setTestState({ result: null, testedPayloadKey: null });
setAdvancedOpen(false);
}
}, [opened, destination?.id]);
const handleSubmit = async (values: DestinationFormValues) => {
const payload = toPayload(values);
try {
if (destination) {
await updateMutation.mutateAsync({
destinationId: destination.id,
name: payload.name,
config: payload.config,
secrets: payload.secrets,
enabled: payload.enabled,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch {}
};
const handleTest = async () => {
if (form.validate().hasErrors) return;
const payload = toPayload(form.values);
const testedPayloadKey = connectionKey(form.values);
setTestState((prev) => ({ ...prev, testedPayloadKey }));
try {
const result = await testMutation.mutateAsync({
type: payload.type,
config: payload.config,
secrets: payload.secrets,
destinationId: destination?.id,
});
setTestState({ result, testedPayloadKey });
} catch {
setTestState({ result: null, testedPayloadKey });
}
};
const type = form.values.type;
const showTls = type !== "datadog";
const currentPayloadKey = connectionKey(form.values);
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
const testPassed = showTestResult && testState.result.delivered;
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
return (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? t("Edit destination") : t("Add destination")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("Preset")}
data={[
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
]}
allowDeselect={false}
disabled={isEdit}
{...form.getInputProps("type")}
/>
<TextInput
label={t("Name")}
placeholder={t("e.g. Splunk prod")}
required
data-autofocus
{...form.getInputProps("name")}
/>
{type === "splunk_hec" && (
<>
<TextInput
label={t("HEC URL")}
placeholder="https://splunk.example.com:8088"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("HEC token")}
required={!hasSecrets.token}
{...form.getInputProps("token")}
/>
</>
)}
{type === "datadog" && (
<>
<Select
label={t("Datadog site")}
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
allowDeselect={false}
{...form.getInputProps("site")}
/>
<PasswordInput
label={t("API key")}
required={!hasSecrets.apiKey}
{...form.getInputProps("apiKey")}
/>
</>
)}
{type === "http" && (
<>
<TextInput
label={t("Endpoint URL")}
placeholder="https://collector.example.com/docmost"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("Token")}
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
{...form.getInputProps("token")}
/>
</>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => setAdvancedOpen((open) => !open)}
style={{ alignSelf: "flex-start" }}
>
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
</Button>
<Collapse expanded={advancedOpen}>
<Stack gap="md">
{type === "splunk_hec" && (
<>
<TextInput
label={t("Index")}
description={t("Leave empty to use the token's default index")}
{...form.getInputProps("index")}
/>
<Group grow>
<TextInput label={t("Source")} {...form.getInputProps("source")} />
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
</Group>
<TextInput
label={t("Host")}
description={t("Defaults to this instance's hostname")}
{...form.getInputProps("host")}
/>
</>
)}
{type === "datadog" && (
<>
<TextInput label={t("Service")} {...form.getInputProps("service")} />
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
</>
)}
{type === "http" && (
<>
<Group grow>
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
</Group>
<Select
label={t("Body format")}
data={[
{ value: "json", label: t("JSON array") },
{ value: "ndjson", label: "NDJSON" },
]}
allowDeselect={false}
{...form.getInputProps("format")}
/>
</>
)}
{showTls && (
<>
{!isCloud() && (
<Switch
label={t("Verify TLS certificate")}
description={
form.values.rejectUnauthorized
? undefined
: t("Insecure: connections can be intercepted.")
}
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
/>
)}
</>
)}
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
</Stack>
</Collapse>
{showTestResult && (
<Alert
color={testState.result.delivered ? "green" : "red"}
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
>
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
</Alert>
)}
<Group justify="space-between" mt="md">
<Group gap="sm">
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
{t("Test connection")}
</Button>
{requiresTest && (
<Text size="xs" c="dimmed">
{t("Test the connection before saving.")}
</Text>
)}
</Group>
<Group>
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
{isEdit ? t("Save") : t("Create")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,15 @@
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
const { t } = useTranslation();
if (!destination.enabled) {
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
}
if (destination.status === "failing") {
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
}
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
}
@@ -0,0 +1,155 @@
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
import {
IconDots,
IconEdit,
IconPlugConnected,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
import { DestinationStatusBadge } from "./destination-status-badge";
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
splunk_hec: "Splunk HEC",
datadog: "Datadog",
http: "Generic HTTP",
};
interface DestinationTableProps {
destinations?: ISiemDestination[];
isLoading?: boolean;
onEdit: (destination: ISiemDestination) => void;
onTest: (destination: ISiemDestination) => void;
onRetry: (destination: ISiemDestination) => void;
onDelete: (destination: ISiemDestination) => void;
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
}
export function DestinationTable({
destinations,
isLoading,
onEdit,
onTest,
onRetry,
onDelete,
onToggle,
}: DestinationTableProps) {
const { t } = useTranslation();
return (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Enabled")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Last delivered")}</Table.Th>
<Table.Th>{t("Last error")}</Table.Th>
<Table.Th aria-label={t("Actions")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{destinations && destinations.length > 0 ? (
destinations.map((destination) => (
<Table.Tr key={destination.id}>
<Table.Td>
<Text fz="sm" fw={500}>{destination.name}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
</Table.Td>
<Table.Td>
<Switch
size="sm"
checked={destination.enabled}
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
aria-label={t("Enabled")}
/>
</Table.Td>
<Table.Td>
<DestinationStatusBadge destination={destination} />
{destination.failingSince &&
(destination.status === "failing" ||
!destination.enabled) && (
<Text
fz="xs"
c="dimmed"
mt={4}
style={{ whiteSpace: "nowrap" }}
>
{t("Failing since {{time}}", {
time: formattedDate(
new Date(destination.failingSince),
),
})}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{destination.lastDeliveredAt
? timeAgo(new Date(destination.lastDeliveredAt))
: t("Never")}
</Text>
</Table.Td>
<Table.Td>
{destination.lastError ? (
<Tooltip label={destination.lastError} multiline w={320}>
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
{destination.lastError}
</Text>
</Tooltip>
) : (
<Text fz="xs" c="dimmed"></Text>
)}
</Table.Td>
<Table.Td>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
{t("Edit")}
</Menu.Item>
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconRefresh size={16} />}
onClick={() => onRetry(destination)}
disabled={!destination.nextAttemptAt}
>
{t("Retry now")}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
!isLoading && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="md">
{t("No destinations yet")}
</Text>
</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,129 @@
import { useState } from "react";
import { Alert, Button, Group, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import {
ISiemDestination,
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
} from "@/ee/siem/types/siem.types";
import {
useRetrySiemDestinationMutation,
useSiemDestinationsQuery,
extractErrorMessage,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import { DestinationTable } from "@/ee/siem/components/destination-table";
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
export default function SiemStreamingPanel() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const hasFeature = useHasFeature(Feature.SIEM);
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
const updateMutation = useUpdateSiemDestinationMutation();
const retryMutation = useRetrySiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const [formOpened, setFormOpened] = useState(false);
const [deleteOpened, setDeleteOpened] = useState(false);
const [selected, setSelected] = useState<ISiemDestination | null>(null);
if (!isOwner) {
return null;
}
const atDestinationLimit =
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
const handleTest = async (destination: ISiemDestination) => {
const result = await testMutation
.mutateAsync({
type: destination.type,
config: destination.config as unknown as Record<string, unknown>,
destinationId: destination.id,
})
.catch(() => null);
if (!result) return;
notifications.show({
message: result.delivered
? t("Test event delivered to {{name}}", { name: destination.name })
: result.error,
color: result.delivered ? "green" : "red",
});
};
return (
<>
{!hasFeature && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
{t("SIEM streaming requires an Enterprise license.")}
</Alert>
)}
<Group justify="flex-end" mb="md">
<Tooltip
label={t("Maximum of {{limit}} destinations reached", {
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
})}
disabled={!atDestinationLimit}
>
<span>
<Button
onClick={() => {
setSelected(null);
setFormOpened(true);
}}
disabled={!hasFeature || atDestinationLimit}
>
{t("Add destination")}
</Button>
</span>
</Tooltip>
</Group>
{isError && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{t("Could not load SIEM destinations: {{message}}", {
message: extractErrorMessage(error),
})}
</Alert>
)}
{hasFeature && !isError && (
<DestinationTable
destinations={data}
isLoading={isLoading}
onEdit={(destination) => {
setSelected(destination);
setFormOpened(true);
}}
onTest={handleTest}
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
onDelete={(destination) => {
setSelected(destination);
setDeleteOpened(true);
}}
onToggle={(destination, enabled) =>
updateMutation.mutate({ destinationId: destination.id, enabled })
}
/>
)}
<DestinationFormModal
opened={formOpened}
onClose={() => setFormOpened(false)}
destination={selected}
/>
<DeleteDestinationModal
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
destination={selected}
/>
</>
);
}
@@ -0,0 +1,179 @@
import {
DATADOG_SITES,
ISiemDestination,
ISiemDestinationInput,
SiemDestinationType,
} from "@/ee/siem/types/siem.types";
export type DestinationFormValues = {
name: string;
type: SiemDestinationType;
url: string;
token: string;
apiKey: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
index: string;
source: string;
sourcetype: string;
host: string;
site: string;
service: string;
tags: string;
rejectUnauthorized: boolean;
enabled: boolean;
};
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
name: "",
type: "splunk_hec",
url: "",
token: "",
apiKey: "",
authHeaderName: "Authorization",
authHeaderPrefix: "Bearer ",
format: "json",
index: "",
source: "docmost",
sourcetype: "docmost:audit",
host: "",
site: DATADOG_SITES[0],
service: "docmost",
tags: "",
rejectUnauthorized: true,
enabled: true,
};
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
export const SECRET_MASK = "********";
export function initialValues(
destination?: ISiemDestination | null,
): DestinationFormValues {
if (!destination) return { ...DEFAULT_FORM_VALUES };
const config = destination.config as Record<string, any>;
const tls = config.tls ?? {};
return {
...DEFAULT_FORM_VALUES,
name: destination.name,
type: destination.type,
enabled: destination.enabled,
token: destination.hasSecrets?.token ? SECRET_MASK : "",
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
url: config.url ?? "",
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
format: config.format ?? "json",
index: config.index ?? "",
source: config.source ?? DEFAULT_FORM_VALUES.source,
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
host: config.host ?? "",
site: config.site ?? DEFAULT_FORM_VALUES.site,
service: config.service ?? DEFAULT_FORM_VALUES.service,
tags: config.tags ?? "",
rejectUnauthorized: tls.rejectUnauthorized ?? true,
};
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function validateForm(
values: DestinationFormValues,
hasSecrets: Record<string, boolean> = {},
): Partial<Record<keyof DestinationFormValues, string>> {
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
if (!values.name.trim()) errors.name = "Name is required";
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
errors.url = "Enter a valid http(s) URL";
}
if (values.type === "splunk_hec") {
if (!values.token && !hasSecrets.token) {
errors.token = "HEC token is required";
}
try {
const url = new URL(values.url.trim());
const path = url.pathname.replace(/\/+$/, "");
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
}
} catch {}
}
if (values.type === "datadog") {
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
errors.site = "Select a Datadog site";
}
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
}
if (values.type === "http") {
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
errors.authHeaderName = "Use letters, digits and hyphens only";
}
}
return errors;
}
function enteredSecret(key: string, value: string): Record<string, string> {
const trimmed = value.trim();
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
}
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
const tls = { rejectUnauthorized: values.rejectUnauthorized };
let config: Record<string, unknown>;
let secrets: Record<string, string>;
switch (values.type) {
case "splunk_hec":
config = {
url: values.url.trim(),
index: values.index.trim(),
source: values.source.trim() || "docmost",
sourcetype: values.sourcetype.trim() || "docmost:audit",
host: values.host.trim(),
tls,
};
secrets = enteredSecret("token", values.token);
break;
case "datadog":
config = {
site: values.site,
service: values.service.trim() || "docmost",
tags: values.tags.trim(),
};
secrets = enteredSecret("apiKey", values.apiKey);
break;
default:
config = {
url: values.url.trim(),
authHeaderName: values.authHeaderName.trim(),
authHeaderPrefix: values.authHeaderPrefix,
format: values.format,
tls,
};
secrets = enteredSecret("token", values.token);
}
return {
name: values.name.trim(),
type: values.type,
config,
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
enabled: values.enabled,
};
}
@@ -0,0 +1,115 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createSiemDestination,
deleteSiemDestination,
getSiemDestinations,
retrySiemDestination,
testSiemDestination,
updateSiemDestination,
} from "@/ee/siem/services/siem-service";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
export function extractErrorMessage(error: Error): string {
const data = (error as any)?.response?.data;
const message = data?.message ?? error.message;
return Array.isArray(message) ? message.join(", ") : String(message);
}
function showError(error: Error) {
notifications.show({ message: extractErrorMessage(error), color: "red" });
}
function isForbidden(error: unknown): boolean {
return (error as { response?: { status?: number } })?.response?.status === 403;
}
export function useSiemDestinationsQuery(
enabled = true,
): UseQueryResult<ISiemDestination[], Error> {
return useQuery({
queryKey: SIEM_DESTINATIONS_KEY,
queryFn: getSiemDestinations,
enabled,
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
});
}
function useInvalidateDestinations() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
}
export function useCreateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
mutationFn: createSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination created") });
invalidate();
},
onError: showError,
});
}
export function useUpdateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
mutationFn: updateSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination updated") });
invalidate();
},
onError: showError,
});
}
export function useDeleteSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: deleteSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination deleted") });
invalidate();
},
onError: showError,
});
}
export function useRetrySiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: retrySiemDestination,
onSuccess: () => {
notifications.show({ message: t("Retry scheduled") });
invalidate();
},
onError: showError,
});
}
export function useTestSiemDestinationMutation() {
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
mutationFn: testSiemDestination,
onError: showError,
});
}
@@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
const req = await api.post<ISiemDestination[]>("/siem/destinations");
return req.data;
}
export async function createSiemDestination(
data: ISiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
return req.data;
}
export async function updateSiemDestination(
data: IUpdateSiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
return req.data;
}
export async function deleteSiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/delete", data);
}
export async function testSiemDestination(
data: ITestSiemDestinationInput,
): Promise<ISiemTestResult> {
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
return req.data;
}
export async function retrySiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/retry", data);
}
@@ -0,0 +1,91 @@
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
export type SiemDestinationStatus = "healthy" | "failing";
export const DATADOG_SITES = [
"datadoghq.com",
"datadoghq.eu",
"us3.datadoghq.com",
"us5.datadoghq.com",
"ap1.datadoghq.com",
"ddog-gov.com",
] as const;
export interface ITlsOptions {
rejectUnauthorized: boolean;
}
export interface IHttpConfig {
url: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
tls?: ITlsOptions;
}
export interface ISplunkHecConfig {
url: string;
index?: string;
source: string;
sourcetype: string;
host?: string;
channelId: string;
tls?: ITlsOptions;
}
export interface IDatadogConfig {
site: string;
service: string;
tags?: string;
}
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
export interface ISiemDestination {
id: string;
name: string;
type: SiemDestinationType;
enabled: boolean;
status: SiemDestinationStatus;
config: ISiemConfig;
hasSecrets: Record<string, boolean>;
cursorCreatedAt: string;
lastDeliveredAt: string | null;
lastError: string | null;
lastErrorAt: string | null;
consecutiveFailures: number;
nextAttemptAt: string | null;
failingSince: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISiemDestinationInput {
name: string;
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface IUpdateSiemDestinationInput {
destinationId: string;
name?: string;
config?: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface ITestSiemDestinationInput {
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
destinationId?: string;
}
export interface ISiemTestResult {
delivered: boolean;
error?: string;
statusCode?: number;
}