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
117 changes: 76 additions & 41 deletions frontend/src/features/soar/components/AgentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ interface Props {
platform?: string
agent?: string
excludedAgents?: string[]
shell?: string
readOnly?: boolean
onChange: (patch: { platform?: string; agent?: string; excludedAgents?: string[]; shell?: string }) => void
}
Expand All @@ -38,7 +37,7 @@ function scopeFrom(agent?: string, excluded?: string[]): Scope {
*
* Platform is a UI-only filter (limits the agent list); the runtime uses
* `agent` + `excludedAgents`. */
export function AgentPicker({ platform, agent, excludedAgents, shell, readOnly, onChange }: Props) {
export function AgentPicker({ platform, agent, excludedAgents, readOnly, onChange }: Props) {
const { t } = useTranslation()
const [agents, setAgents] = useState<AgentOption[]>([])
const [scope, setScope] = useState<Scope>(() => scopeFrom(agent, excludedAgents))
Expand All @@ -62,33 +61,13 @@ export function AgentPicker({ platform, agent, excludedAgents, shell, readOnly,
.catch(() => setAgents([]))
}, [])

const platforms = useMemo(() => {
const seen = new Set<string>()
const out: string[] = []
for (const p of [...COMMON_PLATFORMS, ...agents.map((a) => a.platform), platform ?? '']) {
const v = p.trim()
if (v && !seen.has(v.toLowerCase())) {
seen.add(v.toLowerCase())
out.push(v)
}
}
return out
}, [agents, platform])

const platformAgents = useMemo(() => {
const p = (platform ?? '').trim().toLowerCase()
if (!p) return agents
const m = agents.filter((a) => a.platform && (a.platform.toLowerCase().includes(p) || p.includes(a.platform.toLowerCase())))
return m.length ? m : agents
}, [agents, platform])

const shells = shellsForPlatform(platform ?? '')

const onPlatformChange = (v: string) => {
const nextShell = shells.includes(shell ?? '') ? shell : defaultShellForPlatform(v)
onChange({ platform: v || undefined, shell: nextShell || undefined })
}

const switchScope = (next: Scope) => {
if (next === scope) return
setScope(next)
Expand All @@ -101,25 +80,6 @@ export function AgentPicker({ platform, agent, excludedAgents, shell, readOnly,

return (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<Field label={t('soar.editor.canvas.platform')}>
<select value={platform ?? ''} disabled={readOnly} onChange={(e) => onPlatformChange(e.target.value)} className={SELECT + ' w-full'}>
<option value="">{t('soar.editor.canvas.any')}</option>
{platforms.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
<Field label={t('soar.editor.canvas.shell')}>
<select value={shell ?? ''} disabled={readOnly} onChange={(e) => onChange({ shell: e.target.value || undefined })} className={SELECT + ' w-full'}>
<option value="">{t('soar.editor.canvas.auto')}</option>
{shells.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</Field>
</div>

<Field label={t('soar.editor.canvas.runOn')}>
<div className="inline-flex w-full rounded-md border border-border p-0.5">
{(['auto', 'specific', 'except'] as const).map((s) => (
Expand Down Expand Up @@ -210,6 +170,81 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
)
}

export function platformsForAgents(agents: AgentOption[], platform?: string): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const p of [...COMMON_PLATFORMS, ...agents.map((a) => a.platform), platform ?? '']) {
const v = p.trim()
if (v && !seen.has(v.toLowerCase())) {
seen.add(v.toLowerCase())
out.push(v)
}
}
return out
}

/** Platform + shell selects for `shell` nodes, shown above the command input.
* Picking a platform falls back to its default shell when the current one
* doesn't apply to the new platform. */
export function PlatformShellPicker({
platform,
shell,
readOnly,
onChange,
}: {
platform?: string
shell?: string
readOnly?: boolean
onChange: (patch: { platform?: string; shell?: string }) => void
}) {
const { t } = useTranslation()
const [agents, setAgents] = useState<AgentOption[]>([])

useEffect(() => {
datasourcesHttpService
.list({ page: 1, size: 1000, kind: 'agent' })
.then((r) =>
setAgents(
(r.items ?? []).map((d) => ({
name: d.name,
platform: typeof d.metadata?.osPlatform === 'string' ? d.metadata.osPlatform : '',
})),
),
)
.catch(() => setAgents([]))
}, [])

const platforms = useMemo(() => platformsForAgents(agents, platform), [agents, platform])
const shells = shellsForPlatform(platform ?? '')

const onPlatformChange = (v: string) => {
const nextShells = shellsForPlatform(v)
const nextShell = nextShells.includes(shell ?? '') ? shell : defaultShellForPlatform(v)
onChange({ platform: v || undefined, shell: nextShell || undefined })
}

return (
<div className="grid grid-cols-2 gap-2">
<Field label={t('soar.editor.canvas.platform')}>
<select value={platform ?? ''} disabled={readOnly} onChange={(e) => onPlatformChange(e.target.value)} className={SELECT + ' w-full'}>
<option value="">{t('soar.editor.canvas.any')}</option>
{platforms.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
<Field label={t('soar.editor.canvas.shell')}>
<select value={shell ?? ''} disabled={readOnly} onChange={(e) => onChange({ shell: e.target.value || undefined })} className={SELECT + ' w-full'}>
<option value="">{t('soar.editor.canvas.auto')}</option>
{shells.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</Field>
</div>
)
}

/** Searchable multi-select of agent hostnames. Used for the exclusion list on
* the shell node. Kept local so it doesn't accidentally bleed into other
* parts of the UI. */
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/features/soar/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import { DAGNode } from './nodes/DAGNode'
import { TriggerNode } from './nodes/TriggerNode'
import { computeLayeredLayout, TRIGGER_LAYOUT_ID } from './layeredLayout'

// SAFETY: react-flow nodes carry `data` as an opaque bag; DAGNode/TriggerNode
// declare their own narrower data shape on top of that. The cast bridges the
// generic NodeTypes to our concrete node components.
const NODE_TYPES: NodeTypes = { dag: DAGNode as unknown as NodeTypes[string], trigger: TriggerNode as unknown as NodeTypes[string] }
const TRIGGER_ID = TRIGGER_LAYOUT_ID

Expand Down Expand Up @@ -86,6 +89,9 @@ function FlowCanvasInner({ roots, nodes, conditions, readOnly, onChange, onCondi
id,
type: 'dag',
position: posFor(id),
// SAFETY: react-flow `data` is typed as Record<string, unknown>; the node
// component re-asserts it to its own prop shape. Spreading the FlowNode
// fields in is the whole contract — nothing else is read from `data`.
data: { nodeId: id, ...n } as unknown as Record<string, unknown>,
selected: id === selectedId,
draggable: !readOnly,
Expand Down Expand Up @@ -277,7 +283,7 @@ function FlowCanvasInner({ roots, nodes, conditions, readOnly, onChange, onCondi
if (readOnly) return
const raw = event.dataTransfer.getData('application/soar-node')
if (!raw) return
let payload: { executor: string; kind: NodeKind; paramsPlaceholder?: unknown }
let payload: { executor: string; kind: NodeKind; paramsPlaceholder?: unknown; command?: string; platform?: string; shell?: string }
try {
payload = JSON.parse(raw)
} catch {
Expand All @@ -289,6 +295,9 @@ function FlowCanvasInner({ roots, nodes, conditions, readOnly, onChange, onCondi
kind: payload.kind,
executor: payload.executor,
params: payload.paramsPlaceholder,
command: payload.command,
platform: payload.platform,
shell: payload.shell,
}
layoutRef.current[id] = position
onChange({ roots, nodes: { ...nodes, [id]: newNode } })
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/features/soar/components/NodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { VariablesManager } from "@/features/datasources/components/VariablesMan
import type { FlowNode } from "../types/soar.types";
import { COMMAND_TEMPLATES, shellKindFor } from "../lib/command-templates";
import { mailRecipientError } from "../lib/mail-node-validity";
import { AgentPicker } from "./AgentPicker";
import { AgentPicker, PlatformShellPicker } from "./AgentPicker";
import { ConditionalParamsEditor } from "./ConditionalParamsEditor";
import { HttpParamsEditor } from "./HttpParamsEditor";
import { IncidentParamsEditor } from "./IncidentParamsEditor";
Expand Down Expand Up @@ -229,6 +229,12 @@ export function NodeInspector({
</Field>
{draftNode.executor === "shell" && (
<>
<PlatformShellPicker
platform={draftNode.platform}
shell={draftNode.shell}
readOnly={readOnly}
onChange={updateDraft}
/>
<Field label={t("soar.editor.canvas.commandLabel")}>
{!readOnly && (
<div className="mb-1 flex flex-wrap items-center gap-1.5">
Expand All @@ -237,6 +243,8 @@ export function NodeInspector({
onOpenChange={setTemplatesOpen}
onPick={(cmd) => insertIntoCommand(cmd, true)}
shellKind={shellKind}
disabled={!draftNode.platform}
disabledHint={t("soar.editor.canvas.templatesNeedPlatform")}
/>
<InsertFieldMenu
nodes={nodes}
Expand Down Expand Up @@ -267,7 +275,6 @@ export function NodeInspector({
platform={draftNode.platform}
agent={draftNode.agent}
excludedAgents={draftNode.excludedAgents}
shell={draftNode.shell}
readOnly={readOnly}
onChange={updateDraft}
/>
Expand Down Expand Up @@ -442,11 +449,15 @@ function TemplatesPopover({
onOpenChange,
onPick,
shellKind,
disabled,
disabledHint,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onPick: (command: string) => void;
shellKind: ReturnType<typeof shellKindFor>;
disabled?: boolean;
disabledHint?: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
Expand All @@ -463,8 +474,10 @@ function TemplatesPopover({
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
title={disabled ? disabledHint : undefined}
onClick={() => onOpenChange(!open)}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 text-[10px] hover:bg-muted"
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 text-[10px] hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<Zap size={11} /> {t("soar.editor.canvas.templatesLabel")}{" "}
<ChevronDown size={10} className="opacity-60" />
Expand Down
64 changes: 60 additions & 4 deletions frontend/src/features/soar/components/NodePalette.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AlertOctagon, Bell, Brain, GitBranch, Globe, Mail, Sparkles, Terminal, Zap } from 'lucide-react'
import { useState } from 'react'
import { AlertOctagon, Bell, Brain, ChevronDown, GitBranch, Globe, Mail, Sparkles, Terminal, Zap } from 'lucide-react'
import { EXECUTOR_CATALOG, type ExecutorMeta, type NodeKind } from '../types/soar.types'
import { prebuiltShellNodes } from '../lib/command-templates'

const ICONS: Record<string, typeof Terminal> = {
shell: Terminal,
Expand All @@ -12,6 +14,10 @@ const ICONS: Record<string, typeof Terminal> = {
conditional: GitBranch,
}

/** Platforms with their own prebuilt shell-node section in the palette.
* The section is collapsible — its presets only matter once expanded. */
const PREBUILT_PLATFORMS = ['windows', 'linux', 'darwin']

/** Palette of draggable node types. Each row is one (executor, kind) pair —
* the palette spells them out so the drag payload is unambiguous. */
export function NodePalette({ readOnly }: { readOnly?: boolean }) {
Expand All @@ -20,9 +26,9 @@ export function NodePalette({ readOnly }: { readOnly?: boolean }) {
for (const kind of meta.kinds) rows.push({ meta, kind })
}

const onDragStart = (event: React.DragEvent, meta: ExecutorMeta, kind: NodeKind) => {
const onDragStart = (event: React.DragEvent, payload: Record<string, unknown>) => {
if (readOnly) return
event.dataTransfer.setData('application/soar-node', JSON.stringify({ executor: meta.type, kind, paramsPlaceholder: meta.paramsPlaceholder }))
event.dataTransfer.setData('application/soar-node', JSON.stringify(payload))
event.dataTransfer.effectAllowed = 'move'
}

Expand All @@ -33,13 +39,16 @@ export function NodePalette({ readOnly }: { readOnly?: boolean }) {
<div className="mt-0.5 text-[11px] text-muted-foreground">Drag onto the canvas</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{PREBUILT_PLATFORMS.map((platform) => (
<PrebuiltSection key={platform} platform={platform} readOnly={readOnly} onDragStart={onDragStart} />
))}
{rows.map(({ meta, kind }) => {
const Icon = ICONS[meta.type] ?? Sparkles
return (
<div
key={`${meta.type}:${kind}`}
draggable={!readOnly}
onDragStart={(e) => onDragStart(e, meta, kind)}
onDragStart={(e) => onDragStart(e, { executor: meta.type, kind, paramsPlaceholder: meta.paramsPlaceholder })}
className={
'flex cursor-grab items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5 text-[11px] hover:border-primary/50 hover:bg-muted active:cursor-grabbing ' +
(readOnly ? 'cursor-not-allowed opacity-50' : '')
Expand All @@ -64,3 +73,50 @@ export function NodePalette({ readOnly }: { readOnly?: boolean }) {
</aside>
)
}

function PrebuiltSection({
platform,
readOnly,
onDragStart,
}: {
platform: string
readOnly?: boolean
onDragStart: (event: React.DragEvent, payload: Record<string, unknown>) => void
}) {
const [open, setOpen] = useState(false)
const presets = open ? prebuiltShellNodes(platform) : []

return (
<div className="rounded-md border border-border bg-background">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-[11px] hover:bg-muted"
>
<Terminal size={11} className="shrink-0 text-amber-500" />
<span className="min-w-0 flex-1 truncate text-left font-medium capitalize">{platform}</span>
<ChevronDown size={11} className={'shrink-0 opacity-60 transition-transform ' + (open ? 'rotate-180' : '')} />
</button>
{open && (
<div className="space-y-1 border-t border-border p-1">
{presets.map((p) => (
<div
key={p.id}
draggable={!readOnly}
onDragStart={(e) =>
onDragStart(e, { executor: 'shell', kind: 'executor', command: p.command, platform: p.platform, shell: p.shell })
}
className={
'flex cursor-grab items-center gap-1.5 rounded px-2 py-1 text-[10px] hover:border-primary/50 hover:bg-muted active:cursor-grabbing ' +
(readOnly ? 'cursor-not-allowed opacity-50' : '')
}
title={p.command}
>
<span className="truncate font-medium">{p.label}</span>
</div>
))}
</div>
)}
</div>
)
}
Loading
Loading