Skip to content

Commit b80f41c

Browse files
fix[frontend](soar): added prebuilt templates for soar rules
1 parent 5a9614a commit b80f41c

11 files changed

Lines changed: 273 additions & 2 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useCallback, useEffect, useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { AlertTriangle, FileText, Loader2, Lock, X } from 'lucide-react'
4+
import { Button } from '@/shared/components/ui/button'
5+
import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll'
6+
import { soarTemplatesService } from '../services/soar-templates.service'
7+
import type { ActionTemplate } from '../types/soar.types'
8+
9+
const PAGE_SIZE = 20
10+
11+
export function TemplatePicker({
12+
onPick,
13+
onScratch,
14+
onClose,
15+
}: {
16+
onPick: (t: ActionTemplate) => void
17+
onScratch: () => void
18+
onClose: () => void
19+
}) {
20+
const { t } = useTranslation()
21+
const [items, setItems] = useState<ActionTemplate[]>([])
22+
const [total, setTotal] = useState(0)
23+
const [page, setPage] = useState(0)
24+
const [loading, setLoading] = useState(true)
25+
const [error, setError] = useState(false)
26+
27+
const loadPage = useCallback(async (p: number) => {
28+
setLoading(true)
29+
setError(false)
30+
try {
31+
const res = await soarTemplatesService.list({ page: p, size: PAGE_SIZE })
32+
setTotal(res.total)
33+
setItems((prev) => (p === 0 ? res.data : [...prev, ...res.data]))
34+
} catch {
35+
setError(true)
36+
} finally {
37+
setLoading(false)
38+
}
39+
}, [])
40+
41+
useEffect(() => {
42+
loadPage(page)
43+
}, [page, loadPage])
44+
45+
return (
46+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
47+
<div className="flex max-h-[80vh] w-full max-w-[640px] flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl" onClick={(e) => e.stopPropagation()}>
48+
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-3">
49+
<div className="min-w-0">
50+
<h2 className="truncate text-base font-semibold">{t('soar.templates.title')}</h2>
51+
<p className="mt-0.5 text-[11px] text-muted-foreground">{t('soar.templates.subtitle')}</p>
52+
</div>
53+
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
54+
<X size={15} />
55+
</button>
56+
</header>
57+
58+
<div className="min-h-0 flex-1 overflow-y-auto">
59+
{loading && items.length === 0 ? (
60+
<div className="flex items-center justify-center gap-2 px-6 py-16 text-xs text-muted-foreground">
61+
<Loader2 className="h-4 w-4 animate-spin" /> {t('soar.templates.loading')}
62+
</div>
63+
) : error && items.length === 0 ? (
64+
<div className="flex items-center justify-center gap-2 px-6 py-16 text-xs text-muted-foreground">
65+
<AlertTriangle size={14} className="text-amber-500" /> {t('soar.templates.loadError')}
66+
<Button variant="outline" size="sm" className="ml-2" onClick={() => loadPage(0)}>{t('soar.retry')}</Button>
67+
</div>
68+
) : items.length === 0 ? (
69+
<div className="px-6 py-16 text-center text-xs text-muted-foreground">{t('soar.templates.empty')}</div>
70+
) : (
71+
<div className="divide-y divide-border">
72+
{items.map((tpl) => (
73+
<button
74+
key={tpl.id}
75+
type="button"
76+
onClick={() => onPick(tpl)}
77+
className="flex w-full items-start gap-3 px-5 py-3 text-left transition-colors hover:bg-muted/50"
78+
>
79+
<FileText size={14} className="mt-0.5 shrink-0 text-muted-foreground" />
80+
<div className="min-w-0 flex-1">
81+
<div className="flex items-center gap-2">
82+
<span className="truncate text-sm font-medium">{tpl.title}</span>
83+
{tpl.systemOwner && (
84+
<span className="inline-flex items-center gap-1 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-500">
85+
<Lock size={9} /> {t('soar.system')}
86+
</span>
87+
)}
88+
</div>
89+
{tpl.description && (
90+
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground">{tpl.description}</p>
91+
)}
92+
</div>
93+
</button>
94+
))}
95+
<InfiniteScrollSentinel
96+
onReach={() => setPage((p) => p + 1)}
97+
hasMore={items.length < total}
98+
loading={loading}
99+
endLabel={t('common.allLoaded', { count: total })}
100+
/>
101+
</div>
102+
)}
103+
</div>
104+
105+
<footer className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
106+
<Button variant="ghost" size="sm" onClick={onClose}>{t('soar.templates.cancel')}</Button>
107+
<Button size="sm" onClick={onScratch}>{t('soar.templates.scratch')}</Button>
108+
</footer>
109+
</div>
110+
</div>
111+
)
112+
}

frontend/src/features/soar/pages/FlowsPage.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll'
99
import { soarFlowsService } from '../services/soar-flows.service'
1010
import { soarExecutionsService } from '../services/soar-executions.service'
1111
import { FlowEditor } from '../components/FlowEditor'
12+
import { TemplatePicker } from '../components/TemplatePicker'
1213
import { ExecutionsView } from '../components/ExecutionsView'
13-
import type { Flow } from '../types/soar.types'
14+
import type { ActionTemplate, Flow } from '../types/soar.types'
1415

1516
type PageTab = 'flows' | 'executions'
1617

@@ -85,8 +86,27 @@ function FlowsTab() {
8586
const [loading, setLoading] = useState(true)
8687
const [error, setError] = useState(false)
8788
const [editing, setEditing] = useState<{ flow?: Flow; creating: boolean } | null>(null)
89+
const [pickingTemplate, setPickingTemplate] = useState(false)
8890
const [stats, setStats] = useState<Record<string, FlowStat>>({})
8991

92+
const startFromTemplate = (tpl: ActionTemplate) => {
93+
const seed: Flow = {
94+
relPath: '',
95+
name: tpl.title,
96+
description: tpl.description ?? '',
97+
conditions: [],
98+
commands: [{ command: tpl.command }],
99+
active: true,
100+
agentPlatform: '',
101+
defaultAgent: '',
102+
shell: '',
103+
excludedAgents: [],
104+
systemOwner: false,
105+
}
106+
setPickingTemplate(false)
107+
setEditing({ flow: seed, creating: true })
108+
}
109+
90110
useEffect(() => {
91111
const h = setTimeout(() => {
92112
setDebounced(search.trim())
@@ -186,7 +206,7 @@ function FlowsTab() {
186206
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
187207
</Button>
188208
<div className="ml-auto">
189-
<Button size="sm" onClick={() => setEditing({ creating: true })}>
209+
<Button size="sm" onClick={() => setPickingTemplate(true)}>
190210
<Plus size={14} className="mr-1.5" /> {t('soar.new')}
191211
</Button>
192212
</div>
@@ -239,6 +259,17 @@ function FlowsTab() {
239259
}}
240260
/>
241261
)}
262+
263+
{pickingTemplate && (
264+
<TemplatePicker
265+
onPick={startFromTemplate}
266+
onScratch={() => {
267+
setPickingTemplate(false)
268+
setEditing({ creating: true })
269+
}}
270+
onClose={() => setPickingTemplate(false)}
271+
/>
272+
)}
242273
</>
243274
)
244275
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createApiClient } from '@/shared/lib/api-client'
2+
import type { ActionTemplate, ActionTemplateListQuery } from '../types/soar.types'
3+
4+
const api = createApiClient()
5+
const BASE = '/soar/action-templates'
6+
7+
export const soarTemplatesService = {
8+
// Returns { data: ActionTemplate[], total } — total from X-Total-Count.
9+
list: (q: ActionTemplateListQuery = {}) => {
10+
const p = new URLSearchParams()
11+
p.set('page', String(q.page ?? 0))
12+
p.set('size', String(q.size ?? 20))
13+
return api.getPaged<ActionTemplate[]>(`${BASE}?${p.toString()}`)
14+
},
15+
}

frontend/src/features/soar/types/soar.types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ export interface FlowListQuery {
9090
size?: number
9191
}
9292

93+
/** Reusable command template served from `/soar/action-templates`. Selecting
94+
* one seeds the flow-creation form. */
95+
export interface ActionTemplate {
96+
id: number
97+
title: string
98+
description?: string
99+
command: string
100+
systemOwner: boolean
101+
}
102+
103+
export interface ActionTemplateListQuery {
104+
page?: number // 0-based
105+
size?: number
106+
}
107+
93108
export type ExecutionStatus = 'EXECUTED' | 'PENDING' | 'FAILED'
94109
export type NonExecutionCause = 'AGENT_OFFLINE' | 'AGENT_NOT_FOUND' | 'UNKNOWN'
95110

frontend/src/shared/i18n/locales/de.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4458,6 +4458,15 @@
44584458
"loadError": "Flows konnten nicht geladen werden.",
44594459
"retry": "Erneut versuchen",
44604460
"empty": "Keine Flows für diese Ansicht.",
4461+
"templates": {
4462+
"title": "Neuen Flow starten",
4463+
"subtitle": "Wähle eine Aktionsvorlage oder starte bei Null.",
4464+
"loading": "Vorlagen werden geladen…",
4465+
"loadError": "Vorlagen konnten nicht geladen werden.",
4466+
"empty": "Keine Vorlagen verfügbar.",
4467+
"cancel": "Abbrechen",
4468+
"scratch": "Bei Null anfangen"
4469+
},
44614470
"tabs": {
44624471
"flows": "Flows",
44634472
"executions": "Ausführungsverlauf",
@@ -4514,6 +4523,11 @@
45144523
"commands": "Befehle",
45154524
"commandsHint": "Werden der Reihe nach auf dem Agenten ausgeführt. $(field.path) für Alert-Werte, $[variables.NAME] für gespeicherte Variablen.",
45164525
"addCommand": "Befehl hinzufügen",
4526+
"connector": {
4527+
"always": "immer",
4528+
"success": "bei Erfolg",
4529+
"failure": "bei Fehler"
4530+
},
45174531
"agentPlatform": "Agent-Plattform",
45184532
"shell": "Shell",
45194533
"shellAuto": "auto",

frontend/src/shared/i18n/locales/en.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4622,6 +4622,15 @@
46224622
"loadError": "Could not load flows.",
46234623
"retry": "Retry",
46244624
"empty": "No flows match this view.",
4625+
"templates": {
4626+
"title": "Start a new flow",
4627+
"subtitle": "Pick an action template or start from scratch.",
4628+
"loading": "Loading templates…",
4629+
"loadError": "Could not load templates.",
4630+
"empty": "No templates available.",
4631+
"cancel": "Cancel",
4632+
"scratch": "Start from scratch"
4633+
},
46254634
"tabs": {
46264635
"flows": "Flows",
46274636
"executions": "Execution history",
@@ -4678,6 +4687,11 @@
46784687
"commands": "Commands",
46794688
"commandsHint": "Run in order on the agent. Use $(field.path) for alert values and $[variables.NAME] for stored variables.",
46804689
"addCommand": "Add command",
4690+
"connector": {
4691+
"always": "always",
4692+
"success": "on success",
4693+
"failure": "on failure"
4694+
},
46814695
"agentPlatform": "Agent platform",
46824696
"shell": "Shell",
46834697
"shellAuto": "auto",

frontend/src/shared/i18n/locales/es.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4546,6 +4546,15 @@
45464546
"loadError": "No se pudieron cargar los flujos.",
45474547
"retry": "Reintentar",
45484548
"empty": "Ningún flujo coincide con esta vista.",
4549+
"templates": {
4550+
"title": "Nuevo flujo",
4551+
"subtitle": "Elige una plantilla de acción o empieza desde cero.",
4552+
"loading": "Cargando plantillas…",
4553+
"loadError": "No se pudieron cargar las plantillas.",
4554+
"empty": "No hay plantillas disponibles.",
4555+
"cancel": "Cancelar",
4556+
"scratch": "Empezar desde cero"
4557+
},
45494558
"tabs": {
45504559
"flows": "Flujos",
45514560
"executions": "Historial de ejecuciones",
@@ -4602,6 +4611,11 @@
46024611
"commands": "Comandos",
46034612
"commandsHint": "Se ejecutan en orden en el agente. Usa $(field.path) para valores de la alerta y $[variables.NAME] para variables guardadas.",
46044613
"addCommand": "Agregar comando",
4614+
"connector": {
4615+
"always": "siempre",
4616+
"success": "si tuvo éxito",
4617+
"failure": "si falló"
4618+
},
46054619
"agentPlatform": "Plataforma del agente",
46064620
"shell": "Shell",
46074621
"shellAuto": "auto",

frontend/src/shared/i18n/locales/fr.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4458,6 +4458,15 @@
44584458
"loadError": "Impossible de charger les flux.",
44594459
"retry": "Réessayer",
44604460
"empty": "Aucun flux ne correspond à cette vue.",
4461+
"templates": {
4462+
"title": "Nouveau flux",
4463+
"subtitle": "Choisis un modèle d'action ou pars de zéro.",
4464+
"loading": "Chargement des modèles…",
4465+
"loadError": "Impossible de charger les modèles.",
4466+
"empty": "Aucun modèle disponible.",
4467+
"cancel": "Annuler",
4468+
"scratch": "Partir de zéro"
4469+
},
44614470
"tabs": {
44624471
"flows": "Flux",
44634472
"executions": "Historique d'exécutions",
@@ -4514,6 +4523,11 @@
45144523
"commands": "Commandes",
45154524
"commandsHint": "Exécutées dans l'ordre sur l'agent. Utilisez $(field.path) pour les valeurs de l'alerte et $[variables.NAME] pour les variables enregistrées.",
45164525
"addCommand": "Ajouter une commande",
4526+
"connector": {
4527+
"always": "toujours",
4528+
"success": "en cas de succès",
4529+
"failure": "en cas d'échec"
4530+
},
45174531
"agentPlatform": "Plateforme de l'agent",
45184532
"shell": "Shell",
45194533
"shellAuto": "auto",

frontend/src/shared/i18n/locales/it.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4458,6 +4458,15 @@
44584458
"loadError": "Impossibile caricare i flussi.",
44594459
"retry": "Riprova",
44604460
"empty": "Nessun flusso corrisponde a questa vista.",
4461+
"templates": {
4462+
"title": "Nuovo flusso",
4463+
"subtitle": "Scegli un modello di azione o parti da zero.",
4464+
"loading": "Caricamento modelli…",
4465+
"loadError": "Impossibile caricare i modelli.",
4466+
"empty": "Nessun modello disponibile.",
4467+
"cancel": "Annulla",
4468+
"scratch": "Parti da zero"
4469+
},
44614470
"tabs": {
44624471
"flows": "Flussi",
44634472
"executions": "Cronologia esecuzioni",
@@ -4514,6 +4523,11 @@
45144523
"commands": "Comandi",
45154524
"commandsHint": "Eseguiti in ordine sull'agente. Usa $(field.path) per i valori dell'alert e $[variables.NAME] per le variabili salvate.",
45164525
"addCommand": "Aggiungi comando",
4526+
"connector": {
4527+
"always": "sempre",
4528+
"success": "se riuscito",
4529+
"failure": "se fallito"
4530+
},
45174531
"agentPlatform": "Piattaforma dell'agente",
45184532
"shell": "Shell",
45194533
"shellAuto": "auto",

frontend/src/shared/i18n/locales/pt.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4546,6 +4546,15 @@
45464546
"loadError": "Não foi possível carregar os fluxos.",
45474547
"retry": "Tentar de novo",
45484548
"empty": "Nenhum fluxo corresponde a esta visão.",
4549+
"templates": {
4550+
"title": "Novo fluxo",
4551+
"subtitle": "Escolha um modelo de ação ou comece do zero.",
4552+
"loading": "Carregando modelos…",
4553+
"loadError": "Não foi possível carregar os modelos.",
4554+
"empty": "Nenhum modelo disponível.",
4555+
"cancel": "Cancelar",
4556+
"scratch": "Começar do zero"
4557+
},
45494558
"tabs": {
45504559
"flows": "Fluxos",
45514560
"executions": "Histórico de execuções",
@@ -4602,6 +4611,11 @@
46024611
"commands": "Comandos",
46034612
"commandsHint": "Executam em ordem no agente. Use $(field.path) para valores do alerta e $[variables.NAME] para variáveis salvas.",
46044613
"addCommand": "Adicionar comando",
4614+
"connector": {
4615+
"always": "sempre",
4616+
"success": "em caso de sucesso",
4617+
"failure": "em caso de falha"
4618+
},
46054619
"agentPlatform": "Plataforma do agente",
46064620
"shell": "Shell",
46074621
"shellAuto": "auto",

0 commit comments

Comments
 (0)