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) => - {inlineFmt(it)}
)}
)
+ continue
+ }
+ if (isUl(lines[i])) {
+ const items: string[] = []
+ while (i < lines.length && isUl(lines[i])) items.push(lines[i++].trim().replace(/^[-*]\s+/, ''))
+ blocks.push({items.map((it, j) => - {inlineFmt(it)}
)}
)
+ 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()}>
+
+
+
+ {(showForm || rule) && (
+
+
+
+
+
+
+ )}
+ {mode === 'code' ? (
+ // Editable only while editing/creating; system rules (and plain view)
+ // are read-only.
+
+
+
+ ) : (
+
+ {showForm ? : rule ? : null}
+
+ )}
+
+
+ {(showForm || rule) && (
+
+ )}
+
+
+ {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 (
+
+ )
+}
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')}
+