Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 194 additions & 21 deletions frontend/src/features/soar/components/HttpParamsEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2 } from 'lucide-react'
import { Input } from '@/shared/components/ui/input'
import { cn } from '@/shared/lib/utils'
import { isValidHttpUrl, setHttpBodyError } from '../lib/http-node-validity'
Expand Down Expand Up @@ -30,6 +31,8 @@ interface Props {
onChange: (params: HttpParams) => void
}

type PayloadTab = 'body' | 'headers'

// ponytail: URL split via one regex, body highlighted via Prism (already
// vendored). Validity for save-blocking flows through http-node-validity —
// no context wiring.
Expand All @@ -45,6 +48,7 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
const bodyRef = useRef<HTMLTextAreaElement>(null)
const [bodyText, setBodyText] = useState(() => bodyToText(p.body))
const [bodyError, setBodyError] = useState<string | null>(null)
const [tab, setTab] = useState<PayloadTab>('body')

useEffect(() => {
setBodyText(bodyToText(p.body))
Expand All @@ -59,6 +63,10 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
}
}, [showBody, nodeId])

useEffect(() => {
if (showBody) setTab('body')
}, [showBody])

useEffect(() => () => setHttpBodyError(nodeId, null), [nodeId])

const commitUrl = (nextScheme: string, nextRest: string) => {
Expand Down Expand Up @@ -126,6 +134,23 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
}
}

const switchTab = (next: PayloadTab) => {
if (next === tab) return
if (tab === 'body') commitBody(bodyText)
setTab(next)
}

const headers = (showLabel: boolean) => (
<HeaderRows
headers={p.headers}
readOnly={readOnly}
nodes={nodes}
currentNodeId={nodeId}
showLabel={showLabel}
onChange={(next) => onChange({ ...p, headers: next })}
/>
)

return (
<div className="space-y-2">
<div>
Expand Down Expand Up @@ -183,32 +208,180 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }:
))}
</select>
</div>
{showBody && (
{showBody ? (
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t('soar.editor.canvas.http.body')}
</label>
{!readOnly && (
<div className="mb-1 flex flex-wrap items-center gap-1.5">
<InsertFieldMenu nodes={nodes} currentNodeId={nodeId} onInsert={insertIntoBody} />
<div className="flex gap-1 border-b border-border">
<TabButton
active={tab === 'body'}
onClick={() => switchTab('body')}
label={t('soar.editor.canvas.http.body')}
/>
<TabButton
active={tab === 'headers'}
onClick={() => switchTab('headers')}
label={t('soar.editor.canvas.http.headers')}
/>
</div>
{tab === 'body' ? (
<div>
{!readOnly && (
<div className="mb-1 flex flex-wrap items-center gap-1.5">
<InsertFieldMenu nodes={nodes} currentNodeId={nodeId} onInsert={insertIntoBody} />
</div>
)}
<JsonCodeEditor
value={bodyText}
readOnly={readOnly}
placeholder='{"foo":"bar"}'
invalid={Boolean(bodyError)}
onChange={setBodyText}
onBlur={() => commitBody(bodyText)}
textareaRef={bodyRef}
/>
{bodyError && (
<p className="mt-1 text-[10px] text-red-500">
{t('soar.editor.canvas.http.bodyInvalid')}: {bodyError}
</p>
)}
</div>
)}
<JsonCodeEditor
value={bodyText}
readOnly={readOnly}
placeholder='{"foo":"bar"}'
invalid={Boolean(bodyError)}
onChange={setBodyText}
onBlur={() => commitBody(bodyText)}
textareaRef={bodyRef}
/>
{bodyError && (
<p className="mt-1 text-[10px] text-red-500">
{t('soar.editor.canvas.http.bodyInvalid')}: {bodyError}
</p>
) : (
headers(false)
)}
</div>
) : (
headers(true)
)}
</div>
)
}

function TabButton({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-t border-b-2 px-2 py-1 text-[10px] font-medium uppercase tracking-wider transition-colors',
active
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{label}
</button>
)
}

function HeaderRows({
headers,
readOnly,
nodes,
currentNodeId,
showLabel = true,
onChange,
}: {
headers?: Record<string, string>
readOnly?: boolean
nodes: Record<string, FlowNode>
currentNodeId: string
showLabel?: boolean
onChange: (next: Record<string, string> | undefined) => void
}) {
const { t } = useTranslation()
const entries = Object.entries(headers ?? {})
const valueRefs = useRef<Array<HTMLInputElement | null>>([])

const commit = (next: Array<[string, string]>) => {
const out: Record<string, string> = {}
for (const [k, v] of next) {
if (k.trim()) out[k.trim()] = v
}
onChange(Object.keys(out).length > 0 ? out : undefined)
}

const setAt = (i: number, patch: { key?: string; value?: string }) => {
const next = entries.map(([k, v], j) =>
j === i ? ([patch.key ?? k, patch.value ?? v] as [string, string]) : ([k, v] as [string, string]),
)
commit(next)
}

const insertIntoValue = (i: number, token: string) => {
const el = valueRefs.current[i]
const cur = entries[i]?.[1] ?? ''
const start = el?.selectionStart ?? cur.length
const end = el?.selectionEnd ?? cur.length
setAt(i, { value: cur.slice(0, start) + token + cur.slice(end) })
requestAnimationFrame(() => {
const el2 = valueRefs.current[i]
if (!el2) return
el2.focus()
const pos = start + token.length
el2.setSelectionRange(pos, pos)
})
}

return (
<div>
<div className="mb-1 flex items-center justify-between">
{showLabel && (
<label className="block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t('soar.editor.canvas.http.headers')}
</label>
)}
{!readOnly && (
<button
type="button"
onClick={() => commit([...entries, ['', '']])}
className="rounded px-1.5 py-0.5 text-[10px] text-primary hover:bg-muted"
>
{t('soar.editor.canvas.http.addHeader')}
</button>
)}
</div>
{entries.length === 0 && readOnly && (
<p className="text-[10px] text-muted-foreground">—</p>
)}
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={k}
readOnly={readOnly}
onChange={(e) => setAt(i, { key: e.target.value })}
placeholder="Authorization"
className="h-7 w-2/5 font-mono text-[11px]"
/>
<Input
ref={(el) => {
valueRefs.current[i] = el
}}
value={v}
readOnly={readOnly}
onChange={(e) => setAt(i, { value: e.target.value })}
placeholder="Bearer $(variables.apiToken)"
className="h-7 flex-1 font-mono text-[11px]"
/>
{!readOnly && (
<>
<InsertFieldMenu
nodes={nodes}
currentNodeId={currentNodeId}
onInsert={(token) => insertIntoValue(i, token)}
/>
<button
type="button"
onClick={() => commit(entries.filter((_, j) => j !== i))}
className="rounded p-1 text-muted-foreground hover:text-red-500"
title={t('soar.editor.canvas.deleteNode')}
>
<Trash2 size={12} />
</button>
</>
)}
</div>
))}
</div>
</div>
)
}
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/features/soar/components/NodePalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ const ICONS: Record<string, typeof Terminal> = {
}

/** Palette of draggable node types. Each row is one (executor, kind) pair —
* since some executors back both kinds (http, select via kind flag), the
* palette spells them out so the drag payload is unambiguous. */
* the palette spells them out so the drag payload is unambiguous. */
export function NodePalette({ readOnly }: { readOnly?: boolean }) {
const rows: Array<{ meta: ExecutorMeta; kind: NodeKind }> = []
for (const meta of EXECUTOR_CATALOG) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/soar/types/soar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export interface ExecutorMeta {

export const EXECUTOR_CATALOG: ExecutorMeta[] = [
{ type: 'shell', label: 'Shell (endpoint agent)', kinds: ['executor'] },
{ type: 'http', label: 'HTTP call', kinds: ['executor', 'enrichment'], paramsPlaceholder: { method: 'GET', url: '' } },
{ type: 'http', label: 'HTTP call', kinds: ['enrichment'], paramsPlaceholder: { method: 'GET', url: '' } },
{ type: 'llm_enrich', label: 'LLM enrichment', kinds: ['enrichment'], paramsPlaceholder: { prompt: '' } },
{ type: 'llm_action', label: 'LLM action', kinds: ['executor'], paramsPlaceholder: { prompt: '' } },
{ type: 'notify', label: 'Send notification', kinds: ['executor'], paramsPlaceholder: { message: '', type: 'INFO' } },
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4787,7 +4787,9 @@
"urlInvalid": "Keine gültige http(s)-URL.",
"method": "Methode",
"body": "Body (JSON)",
"bodyInvalid": "Ungültiges JSON"
"bodyInvalid": "Ungültiges JSON",
"headers": "HTTP-Kopfzeilen",
"addHeader": "Kopfzeile hinzufügen"
},
"incident": {
"name": "Incident-Name",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5173,7 +5173,9 @@
"urlInvalid": "Not a valid http(s) URL.",
"method": "Method",
"body": "Body (JSON)",
"bodyInvalid": "Invalid JSON"
"bodyInvalid": "Invalid JSON",
"headers": "HTTP headers",
"addHeader": "Add header"
},
"incident": {
"name": "Incident name",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4909,7 +4909,9 @@
"urlInvalid": "URL http(s) inválida.",
"method": "Método",
"body": "Cuerpo (JSON)",
"bodyInvalid": "JSON inválido"
"bodyInvalid": "JSON inválido",
"headers": "Encabezados HTTP",
"addHeader": "Agregar encabezado"
},
"incident": {
"name": "Nombre del incidente",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4787,7 +4787,9 @@
"urlInvalid": "URL http(s) invalide.",
"method": "Méthode",
"body": "Corps (JSON)",
"bodyInvalid": "JSON invalide"
"bodyInvalid": "JSON invalide",
"headers": "En-têtes HTTP",
"addHeader": "Ajouter un en-tête"
},
"incident": {
"name": "Nom de l'incident",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -4787,7 +4787,9 @@
"urlInvalid": "URL http(s) non valido.",
"method": "Metodo",
"body": "Corpo (JSON)",
"bodyInvalid": "JSON non valido"
"bodyInvalid": "JSON non valido",
"headers": "Intestazioni HTTP",
"addHeader": "Aggiungi intestazione"
},
"incident": {
"name": "Nome dell'incidente",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4909,7 +4909,9 @@
"urlInvalid": "URL http(s) inválida.",
"method": "Método",
"body": "Corpo (JSON)",
"bodyInvalid": "JSON inválido"
"bodyInvalid": "JSON inválido",
"headers": "Cabeçalhos HTTP",
"addHeader": "Adicionar cabeçalho"
},
"incident": {
"name": "Nome do incidente",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -4579,7 +4579,9 @@
"urlInvalid": "Недопустимый http(s) URL.",
"method": "Метод",
"body": "Тело (JSON)",
"bodyInvalid": "Недопустимый JSON"
"bodyInvalid": "Недопустимый JSON",
"headers": "HTTP-заголовки",
"addHeader": "Добавить заголовок"
},
"incident": {
"name": "Название инцидента",
Expand Down
Loading