mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
feat(ee): SIEM (#2471)
This commit is contained in:
@@ -22,6 +22,7 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"user.role_changed": "Changed user role",
|
||||
"user.password_changed": "Changed password",
|
||||
"user.password_reset": "Reset password",
|
||||
"user.password_reset_requested": "Requested password reset",
|
||||
"user.updated": "Updated user",
|
||||
"user.deactivated": "Deactivated user",
|
||||
"user.activated": "Activated user",
|
||||
@@ -62,6 +63,7 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.permission_role_changed": "Changed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
@@ -79,6 +81,13 @@ export const auditEventLabels: Record<string, string> = {
|
||||
|
||||
"license.activated": "Activated license",
|
||||
"license.removed": "Removed license",
|
||||
|
||||
"siem_destination.created": "Created SIEM destination",
|
||||
"siem_destination.updated": "Updated SIEM destination",
|
||||
"siem_destination.deleted": "Deleted SIEM destination",
|
||||
|
||||
"template.created": "Created template",
|
||||
"template.deleted": "Deleted template",
|
||||
};
|
||||
|
||||
export function getEventLabel(event: string): string {
|
||||
@@ -105,6 +114,10 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "user.activated", label: "Activated user" },
|
||||
{ value: "user.role_changed", label: "Changed user role" },
|
||||
{ value: "user.password_changed", label: "Changed password" },
|
||||
{
|
||||
value: "user.password_reset_requested",
|
||||
label: "Requested password reset",
|
||||
},
|
||||
{ value: "user.mfa_enabled", label: "Enabled MFA" },
|
||||
{ value: "user.mfa_disabled", label: "Disabled MFA" },
|
||||
],
|
||||
@@ -147,6 +160,10 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{
|
||||
value: "page.permission_role_changed",
|
||||
label: "Changed page permission",
|
||||
},
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
@@ -193,4 +210,19 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "license.removed", label: "Removed license" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SIEM",
|
||||
items: [
|
||||
{ value: "siem_destination.created", label: "Created SIEM destination" },
|
||||
{ value: "siem_destination.updated", label: "Updated SIEM destination" },
|
||||
{ value: "siem_destination.deleted", label: "Deleted SIEM destination" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Template",
|
||||
items: [
|
||||
{ value: "template.created", label: "Created template" },
|
||||
{ value: "template.deleted", label: "Deleted template" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
|
||||
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
|
||||
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
|
||||
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
|
||||
@@ -48,6 +51,8 @@ export default function AuditLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner } = useUserRole();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [eventFilter, setEventFilter] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -85,6 +90,8 @@ export default function AuditLogs() {
|
||||
|
||||
const { data, isLoading } = useAuditLogsQuery(params);
|
||||
|
||||
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
@@ -94,125 +101,150 @@ export default function AuditLogs() {
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "siem") {
|
||||
navigate("/settings/audit/siem");
|
||||
} else {
|
||||
navigate("/settings/audit");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title={t("Audit log")} />
|
||||
<DocumentTitle title={t("Audit logs & SIEM")} />
|
||||
|
||||
<SettingsTitle title={t("Audit log")} />
|
||||
<SettingsTitle title={t("Audit logs & SIEM")} />
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="audit">
|
||||
{t("Audit logs")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab fw={500} value="siem">
|
||||
{t("SIEM")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Tabs.Panel value="audit" pt="md">
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
||||
<Space h="md" />
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="siem" pt="md">
|
||||
<SiemStreamingPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ export const Feature = {
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user