diff --git a/backend/modules/eventprocessing/connectors/usecase.go b/backend/modules/eventprocessing/connectors/usecase.go index 9c6638ea9..8d35ef684 100644 --- a/backend/modules/eventprocessing/connectors/usecase.go +++ b/backend/modules/eventprocessing/connectors/usecase.go @@ -40,6 +40,7 @@ type CorrelationRuleUsecase interface { Delete(ctx context.Context, relPath string) error SetActive(ctx context.Context, relPath string, active bool) (bool, error) FindDistinctPropertyValues(ctx context.Context, prop, value string) ([]string, error) + ExportRules(ctx context.Context, relPaths []string) ([]dto.ExportedRuleFile, error) } type FilterUsecase interface { diff --git a/backend/modules/eventprocessing/dto/correlation_rule.go b/backend/modules/eventprocessing/dto/correlation_rule.go index 0d58af5a2..2b34a9e9b 100644 --- a/backend/modules/eventprocessing/dto/correlation_rule.go +++ b/backend/modules/eventprocessing/dto/correlation_rule.go @@ -153,3 +153,15 @@ type ImportCorrelationRulesResponse struct { Approved int `json:"approved"` Rejected int `json:"rejected"` } + +// ExportCorrelationRulesRequest carries the identifiers (relPaths) of the rules +// to bundle into a zip. Empty list means "all rules". +type ExportCorrelationRulesRequest struct { + RelPaths []string `json:"relPaths"` +} + +// ExportedRuleFile is one rule YAML resolved from a relPath. +type ExportedRuleFile struct { + Filename string + Content []byte +} diff --git a/backend/modules/eventprocessing/handler/correlation_rule.go b/backend/modules/eventprocessing/handler/correlation_rule.go index dbf68cbea..b63d14dc4 100644 --- a/backend/modules/eventprocessing/handler/correlation_rule.go +++ b/backend/modules/eventprocessing/handler/correlation_rule.go @@ -1,7 +1,10 @@ package handler import ( + "archive/zip" + "bytes" "net/http" + "path/filepath" "strconv" "github.com/gin-gonic/gin" @@ -86,6 +89,57 @@ func (h *CorrelationRuleHandler) Import(c *gin.Context) { }) } +// @Summary Export correlation rules as a zip +// @Description Bundles the requested rules (by relPath) into a single zip of their YAML files. +// @Tags Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce application/zip +// @Param input body dto.ExportCorrelationRulesRequest true "Rule identifiers to export" +// @Success 200 {file} binary +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /correlation-rule/export [post] +func (h *CorrelationRuleHandler) Export(c *gin.Context) { + var req dto.ExportCorrelationRulesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + files, err := h.usecase.ExportRules(c.Request.Context(), req.RelPaths) + if err != nil { + if isNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "correlation rule not found"}) + return + } + writeCorrelationError(c, err) + return + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + w, werr := zw.Create(filepath.Base(f.Filename)) + if werr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip create failed"}) + return + } + if _, werr := w.Write(f.Content); werr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip write failed"}) + return + } + } + if err := zw.Close(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip close failed"}) + return + } + + c.Header("Content-Disposition", `attachment; filename="correlation-rules.zip"`) + c.Data(http.StatusOK, "application/zip", buf.Bytes()) +} + // @Summary Update correlation rule // @Tags Correlation Rules // @Security BearerAuth diff --git a/backend/modules/eventprocessing/routes.go b/backend/modules/eventprocessing/routes.go index 863464132..6d0abc0de 100644 --- a/backend/modules/eventprocessing/routes.go +++ b/backend/modules/eventprocessing/routes.go @@ -31,6 +31,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { cr := g.Group("/correlation-rule") cr.POST("", write, crh.Create) cr.POST("/import", write, crh.Import) + cr.POST("/export", read, crh.Export) cr.PUT("/activate-deactivate", write, crh.ActivateDeactivate) cr.PUT("", write, crh.Update) cr.GET("/search-by-filters", read, crh.List) diff --git a/backend/modules/eventprocessing/usecase/correlation_rule.go b/backend/modules/eventprocessing/usecase/correlation_rule.go index 9ebfee05d..2fe214da2 100644 --- a/backend/modules/eventprocessing/usecase/correlation_rule.go +++ b/backend/modules/eventprocessing/usecase/correlation_rule.go @@ -203,6 +203,21 @@ func (u *correlationRuleUsecase) FindDistinctPropertyValues(_ context.Context, p return u.store.DistinctValues(prop, value), nil } +func (u *correlationRuleUsecase) ExportRules(_ context.Context, relPaths []string) ([]dto.ExportedRuleFile, error) { + if len(relPaths) == 0 { + relPaths = u.store.AllRelPaths() + } + out := make([]dto.ExportedRuleFile, 0, len(relPaths)) + for _, rel := range relPaths { + data, err := u.store.ReadRuleBytes(rel) + if err != nil { + return nil, mapStoreErr(err) + } + out = append(out, dto.ExportedRuleFile{Filename: rel, Content: data}) + } + return out, nil +} + // ── mappers ─────────────────────────────────────────────────────────────────── func buildRule(name, adversary string, conf, integ, avail int, category, technique, description string, diff --git a/backend/modules/eventprocessing/usecase/rule_store.go b/backend/modules/eventprocessing/usecase/rule_store.go index 95814ba01..80a421ba2 100644 --- a/backend/modules/eventprocessing/usecase/rule_store.go +++ b/backend/modules/eventprocessing/usecase/rule_store.go @@ -378,6 +378,33 @@ func (s *RuleStore) SetEnabled(relPath string, enabled bool) (bool, error) { return true, nil } +// AllRelPaths returns every known rule identity in load order (system first, +// then user). +func (s *RuleStore) AllRelPaths() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, 0, len(s.rules)) + for _, sr := range s.rules { + out = append(out, sr.RelPath) + } + return out +} + +// ReadRuleBytes returns the raw on-disk YAML for a rule, preserving comments +// and formatting. Only rules present in the index are readable, so relPath is +// safe against traversal (it must match a known entry). +func (s *RuleStore) ReadRuleBytes(relPath string) ([]byte, error) { + s.mu.RLock() + sr, ok := s.index[relPath] + if !ok { + s.mu.RUnlock() + return nil, ErrRuleNotFound + } + abs := s.absPath(sr) + s.mu.RUnlock() + return os.ReadFile(abs) +} + // DistinctValues returns the distinct values of a rule property, optionally // filtered to those containing `value` (case-insensitive). prop is a legacy // column name (rule_name, rule_category, rule_technique, rule_adversary). diff --git a/frontend/src/features/alerting-rules/components/after-events-view.tsx b/frontend/src/features/alerting-rules/components/after-events-view.tsx new file mode 100644 index 000000000..abf93aa97 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/after-events-view.tsx @@ -0,0 +1,26 @@ +import type { TFunction } from 'i18next' +import { ConditionList } from './condition-list' +import type { AfterStep } from './rule-form' + +export function AfterEventsView({ steps, t }: { steps: AfterStep[]; t: TFunction }) { + return ( +
+ {steps.map((s, i) => ( +
+
+ {t('alertingRules.editor.indexPattern')}: {s.indexPattern || '—'} + {t('alertingRules.editor.within')}: {s.within || '—'} + {t('alertingRules.editor.count')} ≥ {s.count} +
+ {s.with.length > 0 && } + {s.or.map((g, gi) => ( +
+ {t('alertingRules.where.or')} + +
+ ))} +
+ ))} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/cel-node-view.tsx b/frontend/src/features/alerting-rules/components/cel-node-view.tsx new file mode 100644 index 000000000..77781f74a --- /dev/null +++ b/frontend/src/features/alerting-rules/components/cel-node-view.tsx @@ -0,0 +1,34 @@ +import type { TFunction } from 'i18next' +import { cn } from '@/shared/lib/utils' +import type { CelNode } from '../lib/cel-tree' + +export function CelNodeView({ node, t, depth }: { node: CelNode; t: TFunction; depth: number }) { + if (node.type === 'cond') { + return ( +
+ {node.negate && not} + {node.field} + {t(`alertingRules.celOp.${node.fn}`)} + {node.values.filter(Boolean).map((v, i) => ( + {v} + ))} +
+ ) + } + const connector = node.type === 'and' ? t('alertingRules.where.and') : t('alertingRules.where.or') + return ( +
0 && 'rounded-md border border-border/60 bg-background/30 p-2')}> + {node.negate && ( + not + )} + {node.children.map((child, i) => ( +
+ {i > 0 && ( +
{connector}
+ )} + +
+ ))} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/center.tsx b/frontend/src/features/alerting-rules/components/center.tsx new file mode 100644 index 000000000..8fa9c3a91 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/center.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react' + +export function Center({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/frontend/src/features/alerting-rules/components/condition-list.tsx b/frontend/src/features/alerting-rules/components/condition-list.tsx new file mode 100644 index 000000000..b66ab3c8c --- /dev/null +++ b/frontend/src/features/alerting-rules/components/condition-list.tsx @@ -0,0 +1,15 @@ +import type { TFunction } from 'i18next' + +export function ConditionList({ conds, t }: { conds: { field: string; operator: string; value: string }[]; t: TFunction }) { + return ( +
+ {conds.map((c, i) => ( +
+ {c.field} + {t(`alertingRules.operator.${c.operator}`)} + {c.value && {c.value}} +
+ ))} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/definition-view.tsx b/frontend/src/features/alerting-rules/components/definition-view.tsx new file mode 100644 index 000000000..efb65a9ae --- /dev/null +++ b/frontend/src/features/alerting-rules/components/definition-view.tsx @@ -0,0 +1,11 @@ +import type { TFunction } from 'i18next' +import { parseCelTree } from '../lib/cel-tree' +import { CelNodeView } from './cel-node-view' + +export function DefinitionView({ definition, t }: { definition: string; t: TFunction }) { + const tree = parseCelTree(definition) + if (!tree) { + return
{definition || '—'}
+ } + return +} diff --git a/frontend/src/features/alerting-rules/components/import-results-dialog.tsx b/frontend/src/features/alerting-rules/components/import-results-dialog.tsx new file mode 100644 index 000000000..d8e25069a --- /dev/null +++ b/frontend/src/features/alerting-rules/components/import-results-dialog.tsx @@ -0,0 +1,55 @@ +import type { TFunction } from 'i18next' +import { CheckCircle2, Upload, X, XCircle } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import type { ImportRulesResponse } from '../services/alerting-rules-http.service' + +export function ImportResultsDialog({ res, onClose, t }: { res: ImportRulesResponse; onClose: () => void; t: TFunction }) { + return ( +
+
e.stopPropagation()}> +
+

+ {t('alertingRules.import.resultTitle')} +

+ +
+ +
+ + {t('alertingRules.import.approved')}: {res.approved} + + + {t('alertingRules.import.rejected')}: {res.rejected} + +
+ +
+
+ {res.results.map((r, i) => ( +
+ {r.approved ? ( + + ) : ( + + )} +
+
+ {r.filename} + {r.name && {r.name}} +
+ {!r.approved && r.error &&

{r.error}

} +
+
+ ))} +
+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/row.tsx b/frontend/src/features/alerting-rules/components/row.tsx new file mode 100644 index 000000000..5ec5e00f3 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/row.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +export function Row({ k, children }: { k: string; children: ReactNode }) { + return ( + <> +
{k}
+
{children}
+ + ) +} diff --git a/frontend/src/features/alerting-rules/components/rule-description.tsx b/frontend/src/features/alerting-rules/components/rule-description.tsx new file mode 100644 index 000000000..f8025c55d --- /dev/null +++ b/frontend/src/features/alerting-rules/components/rule-description.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' + +function inlineFmt(text: string): ReactNode[] { + const out: ReactNode[] = [] + const re = /\*\*([^*]+)\*\*|`([^`]+)`/g + let last = 0 + let key = 0 + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + if (m.index > last) out.push(text.slice(last, m.index)) + if (m[1] != null) out.push({m[1]}) + else if (m[2] != null) out.push({m[2]}) + last = m.index + m[0].length + } + if (last < text.length) out.push(text.slice(last)) + return out +} + +export function RuleDescription({ text }: { text: string }) { + const lines = text.split('\n') + const blocks: ReactNode[] = [] + let i = 0 + let key = 0 + const isOl = (l: string) => /^\d+[.)]\s+/.test(l.trim()) + const isUl = (l: string) => /^[-*]\s+/.test(l.trim()) + + while (i < lines.length) { + if (lines[i].trim() === '') { + i++ + continue + } + if (isOl(lines[i])) { + const items: string[] = [] + while (i < lines.length && isOl(lines[i])) items.push(lines[i++].trim().replace(/^\d+[.)]\s+/, '')) + blocks.push(
    {items.map((it, j) =>
  1. {inlineFmt(it)}
  2. )}
) + continue + } + if (isUl(lines[i])) { + const items: string[] = [] + while (i < lines.length && isUl(lines[i])) items.push(lines[i++].trim().replace(/^[-*]\s+/, '')) + blocks.push() + continue + } + const para: string[] = [] + while (i < lines.length && lines[i].trim() !== '' && !isOl(lines[i]) && !isUl(lines[i])) para.push(lines[i++].trim()) + blocks.push(

{inlineFmt(para.join(' '))}

) + } + return
{blocks}
+} diff --git a/frontend/src/features/alerting-rules/components/rule-drawer.tsx b/frontend/src/features/alerting-rules/components/rule-drawer.tsx new file mode 100644 index 000000000..e7086fecb --- /dev/null +++ b/frontend/src/features/alerting-rules/components/rule-drawer.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react' +import type { TFunction } from 'i18next' +import { Code2, Download, FlaskConical, LayoutList, Loader2, Lock, Pencil, Trash2, X } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { YamlCodeEditor } from '@/shared/components/YamlCodeEditor' +import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' +import { + alertingRulesHttpService as svc, + AlertingRulesHttpError, + type CorrelationRule, + type DataTypeOption, +} from '../services/alerting-rules-http.service' +import { downloadRuleYaml } from '../lib/download-rule-yaml' +import { ruleFormToYaml, yamlToRuleForm } from '../lib/rule-yaml' +import { RuleForm, ruleToForm, formToInput, type RuleFormState } from './rule-form' +import { RuleView } from './rule-view' +import { Toggle } from './toggle' + +export function RuleDrawer({ + rule, + create, + dataTypeOptions, + onClose, + onToggle, + onDelete, + onSaved, + t, +}: { + rule?: CorrelationRule + create?: boolean + dataTypeOptions: DataTypeOption[] + onClose: () => void + onToggle?: (r: CorrelationRule, next: boolean) => void + onDelete?: (r: CorrelationRule) => void + onSaved: () => void + t: TFunction +}) { + const readOnly = !!rule?.systemOwner + const [editing, setEditing] = useState(!!create) + const [form, setForm] = useState(() => ruleToForm(rule)) + const [busy, setBusy] = useState(false) + const [showTestModal, setShowTestModal] = useState(false) + + // Visual ↔ Code. The structured form stays canonical; Code shows/edits the + // whole rule as YAML and syncs back into the form on toggle / save. + const [mode, setMode] = useState<'visual' | 'code'>('visual') + const [yaml, setYaml] = useState('') + + const toCode = () => { + setYaml(ruleFormToYaml(form)) + setMode('code') + } + const toVisual = () => { + // Only sync YAML → form when actually editing; in view mode (incl. system + // rules) the YAML is read-only, so there's nothing to apply back. + if (showForm) { + const r = yamlToRuleForm(yaml) + if (!r.ok) { + toast.error(t('alertingRules.editor.yamlError', { error: r.error })) + return + } + // active isn't part of the YAML — keep the current value. + setForm({ ...r.form, ruleActive: form.ruleActive }) + } + setMode('visual') + } + + const cancelEdit = () => { + setEditing(false) + setForm(ruleToForm(rule)) + setMode('visual') + } + + const save = async () => { + if (busy) return + // In code mode the YAML is the source of truth — parse it first. + let f = form + if (mode === 'code') { + const r = yamlToRuleForm(yaml) + if (!r.ok) { toast.error(t('alertingRules.editor.yamlError', { error: r.error })); return } + f = { ...r.form, ruleActive: form.ruleActive } // active isn't in the YAML + setForm(f) + } + if (!f.name.trim()) { toast.error(t('alertingRules.editor.nameRequired')); return } + if (!f.definition.trim()) { toast.error(t('alertingRules.editor.definitionRequired')); return } + const input = formToInput(f, create ? undefined : rule?.relPath) + setBusy(true) + try { + if (create) await svc.create(input) + else await svc.update(input) + toast.success(create ? t('alertingRules.toast.created') : t('alertingRules.toast.saved')) + onSaved() + } catch (e) { + toast.error(e instanceof AlertingRulesHttpError ? e.message : t('alertingRules.toast.saveError')) + } finally { + setBusy(false) + } + } + + const showForm = editing || !!create + + return ( +
+
e.stopPropagation()}> +
+
+
+ {create ? t('alertingRules.editor.createTitle') : rule?.category || t('alertingRules.title')} + {readOnly && {t('alertingRules.owner.system')}} +
+

{create ? t('alertingRules.editor.createTitle') : rule?.name}

+
+
+ {rule && !readOnly && !showForm && } + {rule && } + {rule && !readOnly && onDelete && } + {rule && onToggle && onToggle(rule, v)} />} + +
+
+ +
+ {(showForm || rule) && ( +
+
+ + +
+
+ )} + {mode === 'code' ? ( + // Editable only while editing/creating; system rules (and plain view) + // are read-only. +
+ +
+ ) : ( +
+ {showForm ? : rule ? : null} +
+ )} +
+ + {(showForm || rule) && ( +
+ + {showForm && ( +
+ {!create && } + +
+ )} +
+ )} +
+ + {showTestModal && ( + setShowTestModal(false)} + /> + )} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/rule-view.tsx b/frontend/src/features/alerting-rules/components/rule-view.tsx new file mode 100644 index 000000000..30836b558 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/rule-view.tsx @@ -0,0 +1,47 @@ +import type { TFunction } from 'i18next' +import { useDateFormat } from '@/shared/lib/datetime' +import type { CorrelationRule } from '../services/alerting-rules-http.service' +import { asList } from '../lib/as-list' +import { hasItems } from '../lib/has-items' +import { AfterEventsView } from './after-events-view' +import { DefinitionView } from './definition-view' +import { RuleDescription } from './rule-description' +import { Row } from './row' +import { Section } from './section' +import { ruleToForm } from './rule-form' + +export function RuleView({ rule, t }: { rule: CorrelationRule; t: TFunction }) { + const df = useDateFormat() + const steps = ruleToForm(rule).correlation + return ( +
+ {rule.description &&
} +
+
+ {rule.category || '—'} + {rule.technique || '—'} + {rule.adversary ? t(`alertingRules.adversary.${rule.adversary}`) : '—'} + C{rule.confidentiality} · I{rule.integrity} · A{rule.availability} + {(rule.dataTypes ?? []).filter((d) => d.included).map((d) => d.dataType).join(', ') || '—'} + {rule.ruleLastUpdate && {df.formatDateTime(rule.ruleLastUpdate)}} +
+
+
+ +
+ {steps.length > 0 && ( +
+ +
+ )} + {(hasItems(rule.groupBy) || hasItems(rule.deduplicateBy)) && ( +
+
+ {asList(rule.groupBy)} + {asList(rule.deduplicateBy)} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/section.tsx b/frontend/src/features/alerting-rules/components/section.tsx new file mode 100644 index 000000000..906673356 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/section.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/select-all-checkbox.tsx b/frontend/src/features/alerting-rules/components/select-all-checkbox.tsx new file mode 100644 index 000000000..1472fdb4f --- /dev/null +++ b/frontend/src/features/alerting-rules/components/select-all-checkbox.tsx @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react' + +export function SelectAllCheckbox({ checked, indeterminate, onChange, label }: { checked: boolean; indeterminate: boolean; onChange: (v: boolean) => void; label: string }) { + const ref = useRef(null) + useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate }, [indeterminate]) + return ( + onChange(e.target.checked)} + onClick={(e) => e.stopPropagation()} + aria-label={label} + className="h-4 w-4 cursor-pointer accent-primary" + /> + ) +} diff --git a/frontend/src/features/alerting-rules/components/table.tsx b/frontend/src/features/alerting-rules/components/table.tsx new file mode 100644 index 000000000..4fc236c10 --- /dev/null +++ b/frontend/src/features/alerting-rules/components/table.tsx @@ -0,0 +1,67 @@ +import type { TFunction } from 'i18next' +import { Crosshair, Lock } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import type { CorrelationRule } from '../services/alerting-rules-http.service' +import { impactKey } from '../lib/impact-key' +import { maxImpact } from '../lib/max-impact' +import { DataTypeChip } from './rule-form' +import { SelectAllCheckbox } from './select-all-checkbox' +import { Toggle } from './toggle' + +const COLS = '32px 1.4fr 1fr 110px 100px 80px 48px 50px' +const IMPACT_TONE: Record = { high: 'text-red-500', medium: 'text-amber-500', low: 'text-sky-500', none: 'text-muted-foreground' } + +export function Table({ rules, selected, onToggleSelected, onSelectAll, onOpen, onToggle, t }: { rules: CorrelationRule[]; selected: Set; onToggleSelected: (relPath: string) => void; onSelectAll: (checked: boolean) => void; onOpen: (r: CorrelationRule) => void; onToggle: (r: CorrelationRule, next: boolean) => void; t: TFunction }) { + const allChecked = rules.length > 0 && rules.every((r) => selected.has(r.relPath)) + const someChecked = !allChecked && rules.some((r) => selected.has(r.relPath)) + return ( +
+
+
+ +
+
{t('alertingRules.table.name')}
+
{t('alertingRules.table.dataTypes')}
+
{t('alertingRules.table.category')}
+
{t('alertingRules.table.technique')}
+
{t('alertingRules.table.adversary')}
+
{t('alertingRules.table.impact')}
+
{t('alertingRules.table.active')}
+
+ {rules.map((r) => { + const dts = (r.dataTypes ?? []).filter((d) => d.included).map((d) => d.dataType) + return ( +
+
+ onToggleSelected(r.relPath)} + onClick={(e) => e.stopPropagation()} + aria-label={t('alertingRules.table.selectRow', { name: r.name })} + className="h-4 w-4 cursor-pointer accent-primary" + /> +
+ + + + +
{r.adversary ? t(`alertingRules.adversary.${r.adversary}`) : '—'}
+
{maxImpact(r)}
+
onToggle(r, v)} />
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/features/alerting-rules/components/toggle.tsx b/frontend/src/features/alerting-rules/components/toggle.tsx new file mode 100644 index 000000000..fb11e386d --- /dev/null +++ b/frontend/src/features/alerting-rules/components/toggle.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/shared/lib/utils' + +export function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} diff --git a/frontend/src/features/alerting-rules/lib/as-list.ts b/frontend/src/features/alerting-rules/lib/as-list.ts new file mode 100644 index 000000000..a7a18d1b1 --- /dev/null +++ b/frontend/src/features/alerting-rules/lib/as-list.ts @@ -0,0 +1,3 @@ +export function asList(v: unknown): string { + return Array.isArray(v) ? v.join(', ') || '—' : '—' +} diff --git a/frontend/src/features/alerting-rules/lib/download-rule-yaml.ts b/frontend/src/features/alerting-rules/lib/download-rule-yaml.ts new file mode 100644 index 000000000..c2385fc5e --- /dev/null +++ b/frontend/src/features/alerting-rules/lib/download-rule-yaml.ts @@ -0,0 +1,17 @@ +import { ruleToForm } from '../components/rule-form' +import type { CorrelationRule } from '../services/alerting-rules-http.service' +import { ruleFormToYaml } from './rule-yaml' + +export function downloadRuleYaml(rule: CorrelationRule): void { + const yaml = ruleFormToYaml(ruleToForm(rule)) + const base = rule.relPath.split('/').pop() || `${rule.name || 'rule'}.yaml` + const name = /\.ya?ml$/i.test(base) ? base : `${base}.yaml` + const url = URL.createObjectURL(new Blob([yaml], { type: 'text/yaml;charset=utf-8' })) + const a = document.createElement('a') + a.href = url + a.download = name + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/features/alerting-rules/lib/has-items.ts b/frontend/src/features/alerting-rules/lib/has-items.ts new file mode 100644 index 000000000..e3d4dead3 --- /dev/null +++ b/frontend/src/features/alerting-rules/lib/has-items.ts @@ -0,0 +1,3 @@ +export function hasItems(v: unknown): boolean { + return Array.isArray(v) ? v.length > 0 : v != null && typeof v === 'object' && Object.keys(v).length > 0 +} diff --git a/frontend/src/features/alerting-rules/lib/impact-key.ts b/frontend/src/features/alerting-rules/lib/impact-key.ts new file mode 100644 index 000000000..c3a34ade5 --- /dev/null +++ b/frontend/src/features/alerting-rules/lib/impact-key.ts @@ -0,0 +1,6 @@ +export function impactKey(n: number): 'high' | 'medium' | 'low' | 'none' { + if (n >= 3) return 'high' + if (n === 2) return 'medium' + if (n === 1) return 'low' + return 'none' +} diff --git a/frontend/src/features/alerting-rules/lib/max-impact.ts b/frontend/src/features/alerting-rules/lib/max-impact.ts new file mode 100644 index 000000000..9ca53a6eb --- /dev/null +++ b/frontend/src/features/alerting-rules/lib/max-impact.ts @@ -0,0 +1,3 @@ +export function maxImpact(r: { confidentiality: number; integrity: number; availability: number }): number { + return Math.max(r.confidentiality, r.integrity, r.availability) +} diff --git a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx index 8b4b49c4e..d96720ac9 100644 --- a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx +++ b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx @@ -1,25 +1,16 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' import { AlertTriangle, - CheckCircle2, - Code2, - Crosshair, + Download, FlaskConical, - LayoutList, Loader2, - Lock, - Pencil, Plus, RefreshCw, Search, ShieldAlert, - Trash2, Upload, - X, - XCircle, } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/shared/lib/utils' @@ -27,8 +18,6 @@ import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog' -import { YamlCodeEditor } from '@/shared/components/YamlCodeEditor' -import { useDateFormat } from '@/shared/lib/datetime' import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' import { alertingRulesHttpService as svc, @@ -37,25 +26,12 @@ import { type DataTypeOption, type ImportRulesResponse, } from '../services/alerting-rules-http.service' -import { RuleForm, ruleToForm, formToInput, DataTypeChip, type RuleFormState } from '../components/rule-form' -import { ruleFormToYaml, yamlToRuleForm } from '../lib/rule-yaml' -import { parseCelTree, type CelNode } from '../lib/cel-tree' +import { Center } from '../components/center' +import { ImportResultsDialog } from '../components/import-results-dialog' +import { RuleDrawer } from '../components/rule-drawer' +import { Table } from '../components/table' const SELECT_CLS = 'h-9 rounded-md border border-border bg-background px-2 text-sm' -const COLS = '1.4fr 1fr 110px 100px 80px 48px 50px' - -function maxImpact(r: { confidentiality: number; integrity: number; availability: number }): number { - return Math.max(r.confidentiality, r.integrity, r.availability) -} -function impactKey(n: number): 'high' | 'medium' | 'low' | 'none' { - if (n >= 3) return 'high' - if (n === 2) return 'medium' - if (n === 1) return 'low' - return 'none' -} -const IMPACT_TONE: Record = { high: 'text-red-500', medium: 'text-amber-500', low: 'text-sky-500', none: 'text-muted-foreground' } - -/* ─── Page ─────────────────────────────────────────────────────────────── */ export function AlertingRulesPage() { const { t } = useTranslation() @@ -89,6 +65,37 @@ export function AlertingRulesPage() { const [importBusy, setImportBusy] = useState(false) const [importResults, setImportResults] = useState(null) const [showTestModal, setShowTestModal] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [exportBusy, setExportBusy] = useState(false) + + const toggleSelected = useCallback((relPath: string) => { + setSelected((cur) => { + const next = new Set(cur) + if (next.has(relPath)) next.delete(relPath) + else next.add(relPath) + return next + }) + }, []) + + const exportSelected = async () => { + if (exportBusy) return + setExportBusy(true) + try { + const blob = await svc.exportRules([...selected]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `alerting-rules-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.zip` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } catch (e) { + toast.error(e instanceof AlertingRulesHttpError ? e.message : t('alertingRules.export.error')) + } finally { + setExportBusy(false) + } + } const onImportFiles = async (fileList: FileList | null) => { if (!fileList || fileList.length === 0) return @@ -215,6 +222,10 @@ export function AlertingRulesPage() { {importBusy ? : } {t('alertingRules.import.button')} +