diff --git a/frontend/.gitignore b/frontend/.gitignore index a578fda4a..307c941fe 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -7,3 +7,4 @@ dist-ssr/ .vite/ .DS_Store *.tsbuildinfo +.config.ts \ No newline at end of file diff --git a/frontend/src/features/soar/components/FlowEditor.tsx b/frontend/src/features/soar/components/FlowEditor.tsx index 7ae3e6af3..c00470630 100644 --- a/frontend/src/features/soar/components/FlowEditor.tsx +++ b/frontend/src/features/soar/components/FlowEditor.tsx @@ -172,7 +172,7 @@ export function FlowEditor({

- {form.name.trim() || (creating ? t('soar.editor.createTitle') : (flow?.name ?? ''))} + {form.name.trim() || (creating ? t('soar.new') : (flow?.name ?? t('soar.new')))} {!readOnly && (

+ {fullUrlPreview && ( +

+ {fullUrlPreview} +

+ )} {urlInvalid && (

{t('soar.editor.canvas.http.urlInvalid')} @@ -208,49 +271,57 @@ export function HttpParamsEditor({ nodeId, nodes, params, readOnly, onChange }: ))} - {showBody ? ( -

-
+
+
+ switchTab('headers')} + label={t('soar.editor.canvas.http.headers')} + /> + switchTab('params')} + label={t('soar.editor.canvas.http.params')} + /> + {showBody && ( switchTab('body')} label={t('soar.editor.canvas.http.body')} /> - switchTab('headers')} - label={t('soar.editor.canvas.http.headers')} - /> -
- {tab === 'body' ? ( -
- {!readOnly && ( -
- -
- )} - commitBody(bodyText)} - textareaRef={bodyRef} - /> - {bodyError && ( -

- {t('soar.editor.canvas.http.bodyInvalid')}: {bodyError} -

- )} -
- ) : ( - headers(false) )}
- ) : ( - headers(true) - )} + + + {showBody && ( + + )} +
) } @@ -272,15 +343,23 @@ function TabButton({ active, onClick, label }: { active: boolean; onClick: () => ) } -function HeaderRows({ - headers, +function KeyValueRows({ + title, + values, + defaultRows = [], + addLabel, + cacheKey, readOnly, nodes, currentNodeId, showLabel = true, onChange, }: { - headers?: Record + title: string + values?: Record + defaultRows?: Array<[string, string]> + addLabel: string + cacheKey: string readOnly?: boolean nodes: Record currentNodeId: string @@ -288,27 +367,63 @@ function HeaderRows({ onChange: (next: Record | undefined) => void }) { const { t } = useTranslation() - const entries = Object.entries(headers ?? {}) + + const incomingRows = values && Object.keys(values).length > 0 ? Object.entries(values) : [] + const cachedRows = HTTP_ROWS_CACHE.get(cacheKey) + const initialRows = cachedRows?.rows ?? (incomingRows.length > 0 ? incomingRows : defaultRows) + const initialSelected = cachedRows?.selected ?? initialRows.map(() => true) + const [rows, setRows] = useState(() => initialRows) + const [selected, setSelected] = useState(() => initialSelected) const valueRefs = useRef>([]) + const cacheRef = useRef({ rows: [...initialRows], selected: [...initialSelected] }) + + useEffect(() => { + const incomingRows = values ? Object.entries(values) : [] + const incomingByKey = new Map(incomingRows) + const cachedKeys = new Set(cacheRef.current.rows.map(([key]) => key)) + const nextRows = cacheRef.current.rows.map(([key, value]) => + incomingByKey.has(key) ? [key, incomingByKey.get(key) ?? value] : [key, value], + ) as Array<[string, string]> + + incomingRows.forEach(([key, value]) => { + if (!cachedKeys.has(key)) nextRows.push([key, value]) + }) + + cacheRef.current.rows = nextRows + setRows(nextRows) + const selectedByKey = new Map( + cacheRef.current.rows.map(([key], index) => [key, cacheRef.current.selected[index] ?? true]), + ) + const nextSelected = nextRows.map(([key]) => selectedByKey.get(key) ?? true) + cacheRef.current.selected = nextSelected + HTTP_ROWS_CACHE.set(cacheKey, cacheRef.current) + setSelected(nextSelected) + }, [values, defaultRows, cacheKey]) - const commit = (next: Array<[string, string]>) => { + const commit = (next: Array<[string, string]>, nextSelected: boolean[] = selected) => { const out: Record = {} - for (const [k, v] of next) { + next.forEach(([k, v], index) => { + if (!nextSelected[index]) return 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) => + const next = rows.map(([k, v], j) => j === i ? ([patch.key ?? k, patch.value ?? v] as [string, string]) : ([k, v] as [string, string]), ) + cacheRef.current.rows = next + HTTP_ROWS_CACHE.set(cacheKey, cacheRef.current) + setRows(next) commit(next) } + const visibleRows = rows.map((row, index) => ({ row, selected: selected[index] ?? true })) + const insertIntoValue = (i: number, token: string) => { const el = valueRefs.current[i] - const cur = entries[i]?.[1] ?? '' + const cur = rows[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) }) @@ -321,71 +436,162 @@ function HeaderRows({ }) } + const toggleRow = (index: number) => { + const nextSelected = selected.map((isSelected, i) => (i === index ? !isSelected : isSelected)) + cacheRef.current.selected = nextSelected + HTTP_ROWS_CACHE.set(cacheKey, cacheRef.current) + setSelected(nextSelected) + commit(rows, nextSelected) + } + + const removeRow = (index: number) => { + const nextRows = rows.filter((_, i) => i !== index) + const nextSelected = selected.filter((_, i) => i !== index) + cacheRef.current.rows = nextRows + cacheRef.current.selected = nextSelected + HTTP_ROWS_CACHE.set(cacheKey, cacheRef.current) + setRows(nextRows) + setSelected(nextSelected.length > 0 ? nextSelected : []) + commit(nextRows, nextSelected) + } + + const addRow = () => { + const nextRows: Array<[string, string]> = [...rows, ['', ''] as [string, string]] + const nextSelected = [...selected, true] + cacheRef.current.rows = nextRows + cacheRef.current.selected = nextSelected + HTTP_ROWS_CACHE.set(cacheKey, cacheRef.current) + setRows(nextRows) + setSelected(nextSelected) + } + return (
{showLabel && ( )} {!readOnly && ( )}
- {entries.length === 0 && readOnly && ( + {rows.length === 0 && readOnly && (

)}
- {entries.map(([k, v], i) => ( -
- setAt(i, { key: e.target.value })} - placeholder="Authorization" - className="h-7 w-2/5 font-mono text-[11px]" - /> - { - 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 && ( - <> - insertIntoValue(i, token)} +
+ + {t('soar.editor.canvas.http.key') || 'Key'} + {t('soar.editor.canvas.http.value') || 'Value'} +
+ {visibleRows.map(({ row, selected: isSelected }, i) => { + const [k, v] = row + return ( +
+
+ {!readOnly && ( + toggleRow(i)} + className="h-4 w-4 rounded border-input accent-primary" + aria-label={`Toggle ${k || 'header'} ${title.toLowerCase()}`} + /> + )} +
+ setAt(i, { key: e.target.value })} + placeholder={t('soar.editor.canvas.http.key') || 'Key'} + className="h-7 font-mono text-[11px]" + /> +
+ { + valueRefs.current[i] = el + }} + value={v} + readOnly={readOnly} + onChange={(e) => setAt(i, { value: e.target.value })} + placeholder={t('soar.editor.canvas.http.value') || 'Value'} + className="h-7 flex-1 font-mono text-[11px]" /> - - - )} -
- ))} + {!readOnly && ( + insertIntoValue(i, token)} + /> + )} +
+ {!readOnly && ( +
+ +
+ )} +
+ ) + })}
) } +function HeaderRows({ + headers, + readOnly, + nodes, + currentNodeId, + showLabel = true, + onChange, +}: { + headers?: Record + readOnly?: boolean + nodes: Record + currentNodeId: string + showLabel?: boolean + onChange: (next: Record | undefined) => void +}) { + const { t } = useTranslation() + + return ( + + ) +} + function normalize(params: unknown): HttpParams { if (!params || typeof params !== 'object') return {} return params as HttpParams @@ -393,8 +599,63 @@ function normalize(params: unknown): HttpParams { function splitUrl(url: string): { scheme: string; rest: string } { const m = /^(https?):\/\/(.*)$/i.exec(url.trim()) - if (m) return { scheme: m[1].toLowerCase(), rest: m[2] } - return { scheme: 'https', rest: url } + const value = m ? m[2] : url + const queryIndex = value.indexOf('?') + const hashIndex = value.indexOf('#') + const suffixIndex = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0] + const rest = suffixIndex === undefined ? value : value.slice(0, suffixIndex) + return { scheme: m ? m[1].toLowerCase() : 'https', rest } +} + +function parseQueryParams(url: string): Record { + const queryStart = url.indexOf('?') + if (queryStart < 0) return {} + + const hashStart = url.indexOf('#', queryStart) + const query = url.slice(queryStart + 1, hashStart >= 0 ? hashStart : undefined) + const values: Record = {} + new URLSearchParams(query).forEach((value, key) => { + values[key] = value + }) + return values +} + +function rowsToRecord(rows?: Row[], selected?: boolean[]): Record { + const values: Record = {} + rows?.forEach(([key, value], index) => { + if (selected?.[index] === false || !key.trim()) return + values[key.trim()] = value + }) + return values +} + +function getActiveQueryParams( + nodeId: string, + current: Record, + fromUrl: Record, +): Record { + if (Object.keys(current).length > 0) return current + const cached = HTTP_ROWS_CACHE.get(`${nodeId}:params`) + const cachedValues = rowsToRecord(cached?.rows, cached?.selected) + if (Object.keys(cachedValues).length > 0) return cachedValues + return fromUrl +} + +function appendQueryParams(url: string | undefined, params?: Record): string { + if (!url) return '' + + const hashIndex = url.indexOf('#') + const hash = hashIndex >= 0 ? url.slice(hashIndex) : '' + const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url + const base = withoutHash.split('?')[0] + const query = new URLSearchParams() + + Object.entries(params ?? {}).forEach(([key, value]) => { + if (key.trim()) query.set(key.trim(), value) + }) + + const serialized = query.toString() + return `${base}${serialized ? `?${serialized}` : ''}${hash}` } function bodyToText(body: unknown): string { diff --git a/frontend/src/features/soar/types/soar.types.ts b/frontend/src/features/soar/types/soar.types.ts index d25394d91..981260a4b 100644 --- a/frontend/src/features/soar/types/soar.types.ts +++ b/frontend/src/features/soar/types/soar.types.ts @@ -150,7 +150,23 @@ export interface ExecutorMeta { export const EXECUTOR_CATALOG: ExecutorMeta[] = [ { type: 'shell', label: 'Shell (endpoint agent)', kinds: ['executor'] }, - { type: 'http', label: 'HTTP call', kinds: ['enrichment'], paramsPlaceholder: { method: 'GET', url: '' } }, + { + type: 'http', + label: 'HTTP call', + kinds: ['enrichment'], + paramsPlaceholder: { + method: 'GET', + url: '', + headers: { + 'Postman-Token': '', + Host: '', + 'User-Agent': 'PostmanRuntime/7.43.0', + Accept: '*/*', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + }, + }, + }, { 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' } }, diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 400e222c0..24ebf7411 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -4789,7 +4789,11 @@ "body": "Body (JSON)", "bodyInvalid": "Ungültiges JSON", "headers": "HTTP-Kopfzeilen", - "addHeader": "Kopfzeile hinzufügen" + "params": "Abfrageparameter", + "key": "Schlüssel", + "value": "Wert", + "addHeader": "Kopfzeile hinzufügen", + "addParam": "Parameter hinzufügen" }, "incident": { "name": "Incident-Name", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index f64b8014f..fb5ea5839 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -5175,7 +5175,11 @@ "body": "Body (JSON)", "bodyInvalid": "Invalid JSON", "headers": "HTTP headers", - "addHeader": "Add header" + "params": "Query params", + "key": "Key", + "value": "Value", + "addHeader": "Add header", + "addParam": "Add param" }, "incident": { "name": "Incident name", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 5bcfdc39c..97b1d22a6 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -4911,7 +4911,11 @@ "body": "Cuerpo (JSON)", "bodyInvalid": "JSON inválido", "headers": "Encabezados HTTP", - "addHeader": "Agregar encabezado" + "params": "Parámetros de consulta", + "key": "Clave", + "value": "Valor", + "addHeader": "Agregar encabezado", + "addParam": "Agregar parámetro" }, "incident": { "name": "Nombre del incidente", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index aa77acdf7..364be06b1 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -4789,7 +4789,11 @@ "body": "Corps (JSON)", "bodyInvalid": "JSON invalide", "headers": "En-têtes HTTP", - "addHeader": "Ajouter un en-tête" + "params": "Paramètres de requête", + "key": "Clé", + "value": "Valeur", + "addHeader": "Ajouter un en-tête", + "addParam": "Ajouter un paramètre" }, "incident": { "name": "Nom de l'incident", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index b7129fc1e..7726145a5 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -4789,7 +4789,11 @@ "body": "Corpo (JSON)", "bodyInvalid": "JSON non valido", "headers": "Intestazioni HTTP", - "addHeader": "Aggiungi intestazione" + "params": "Parametri di query", + "key": "Chiave", + "value": "Valore", + "addHeader": "Aggiungi intestazione", + "addParam": "Aggiungi parametro" }, "incident": { "name": "Nome dell'incidente", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index e95ba0418..c2fc8d3a7 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -4911,7 +4911,11 @@ "body": "Corpo (JSON)", "bodyInvalid": "JSON inválido", "headers": "Cabeçalhos HTTP", - "addHeader": "Adicionar cabeçalho" + "params": "Parâmetros da consulta", + "key": "Chave", + "value": "Valor", + "addHeader": "Adicionar cabeçalho", + "addParam": "Adicionar parâmetro" }, "incident": { "name": "Nome do incidente", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 783059061..61dab9413 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -4581,7 +4581,11 @@ "body": "Тело (JSON)", "bodyInvalid": "Недопустимый JSON", "headers": "HTTP-заголовки", - "addHeader": "Добавить заголовок" + "params": "Параметры запроса", + "key": "Ключ", + "value": "Значение", + "addHeader": "Добавить заголовок", + "addParam": "Добавить параметр" }, "incident": { "name": "Название инцидента",