wulf-pulse/components/admin/pipeline/StepConfigEditor.tsx

1333 lines
51 KiB
TypeScript

'use client';
import { useState, useRef } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Variable,
Plus,
Trash2,
GripVertical,
ChevronDown,
Copy,
} from 'lucide-react';
// ============================================================================
// Available fields per trigger source
// ============================================================================
const TRIGGER_FIELDS: Record<string, { key: string; label: string; group: string }[]> = {
datto_rmm: [
{ key: 'triggered', label: 'Triggered (True/False)', group: 'Alert' },
{ key: 'alert_type', label: 'Alert Type', group: 'Alert' },
{ key: 'alert_category', label: 'Alert Category', group: 'Alert' },
{ key: 'alert_priority', label: 'Alert Priority', group: 'Alert' },
{ key: 'alert_message_en', label: 'Alert Message', group: 'Alert' },
{ key: 'alert_uid', label: 'Alert UID', group: 'Alert' },
{ key: 'device_hostname', label: 'Device Hostname', group: 'Device' },
{ key: 'device_id', label: 'Device ID', group: 'Device' },
{ key: 'device_uid', label: 'Device UID', group: 'Device' },
{ key: 'device_ip', label: 'Device IP', group: 'Device' },
{ key: 'device_os', label: 'Device OS', group: 'Device' },
{ key: 'device_description', label: 'Device Description', group: 'Device' },
{ key: 'last_user', label: 'Last User', group: 'Device' },
{ key: 'site_name', label: 'Site Name', group: 'Site' },
{ key: 'site_id', label: 'Site ID', group: 'Site' },
{ key: 'site_uid', label: 'Site UID', group: 'Site' },
{ key: 'platform', label: 'Platform', group: 'Site' },
...Array.from({ length: 29 }, (_, i) => ({
key: `device_udf${i + 1}`, label: `UDF ${i + 1}`, group: 'UDF',
})),
],
autotask: [
{ key: 'Action', label: 'Action (Create/Update/Delete)', group: 'Event' },
{ key: 'EntityType', label: 'Entity Type', group: 'Event' },
{ key: 'Id', label: 'Entity ID', group: 'Event' },
{ key: 'EventTime', label: 'Event Time', group: 'Event' },
{ key: 'SequenceNumber', label: 'Sequence Number', group: 'Event' },
{ key: 'PersonId', label: 'Person ID', group: 'Event' },
],
veeam: [
{ key: 'eventType', label: 'Event Type', group: 'Event' },
{ key: 'severity', label: 'Severity', group: 'Event' },
{ key: 'message', label: 'Message', group: 'Event' },
],
manual: [],
};
// Fields produced by enrichment steps
const STEP_OUTPUT_FIELDS: Record<string, { key: string; label: string }[]> = {
enrich_device: [
{ key: 'device_found', label: 'Device Found (bool)' },
{ key: 'device_hostname', label: 'Device Hostname' },
{ key: 'device_os', label: 'Device OS' },
{ key: 'device_ip', label: 'Device IP' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'company_name', label: 'Company Name' },
],
enrich_company: [
{ key: 'company_found', label: 'Company Found (bool)' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'company_name', label: 'Company Name' },
],
enrich_ticket: [
{ key: 'ticket_found', label: 'Ticket Found (bool)' },
{ key: 'ticket_id', label: 'Ticket ID' },
{ key: 'ticket_number', label: 'Ticket Number' },
{ key: 'ticket_title', label: 'Ticket Title' },
],
transform: [], // dynamic — comes from mappings
set_variable: [], // dynamic
create_ticket: [
{ key: 'ticket_id', label: 'Created Ticket ID' },
{ key: 'ticket_number', label: 'Created Ticket Number' },
],
ai_analyze: [
{ key: 'ai_response', label: 'AI Response' },
{ key: 'ai_provider', label: 'AI Provider' },
{ key: 'ai_model', label: 'AI Model' },
],
approval: [
{ key: 'approval_id', label: 'Approval ID' },
{ key: 'approval_result', label: 'Approval Result' },
],
rmm_quick_job: [
{ key: 'job_uid', label: 'Job UID' },
{ key: 'quick_job_result', label: 'Job Result' },
],
rmm_get_job_results: [
{ key: 'job_results', label: 'Job Results' },
{ key: 'job_status', label: 'Job Status' },
],
notify: [{ key: 'notified', label: 'Notified (bool)' }],
enrich_vspc: [
{ key: 'vspc_found', label: 'VSPC Found (bool)' },
{ key: 'vspc_last_job_status', label: 'Last Job Status' },
{ key: 'vspc_last_success', label: 'Last Success Date' },
{ key: 'vspc_hours_since_success', label: 'Hours Since Success' },
{ key: 'vspc_failure_message', label: 'Failure Message' },
{ key: 'vspc_restore_points', label: 'Restore Points' },
{ key: 'vspc_backed_up_size', label: 'Backed Up Size' },
{ key: 'vspc_alarm_count', label: 'Alarm Count' },
{ key: 'vspc_failed_job_count', label: 'Failed Job Count' },
{ key: 'vspc_summary', label: 'VSPC Summary Text' },
{ key: 'vspc_agent_jobs', label: 'Agent Jobs (array)' },
{ key: 'vspc_server_jobs', label: 'Server Jobs (array)' },
{ key: 'vspc_workloads', label: 'Workloads (array)' },
{ key: 'vspc_alarms', label: 'Alarms (array)' },
],
db_query: [], // dynamic — output_key from config
fetch_b2_result: [], // dynamic — output_key from config
};
// Autotask ticket fields for create_ticket
const TICKET_FIELDS = [
{ key: 'title', label: 'Title', required: true, type: 'text' as const },
{ key: 'description', label: 'Description', required: false, type: 'textarea' as const },
{ key: 'companyID', label: 'Company ID', required: true, type: 'variable' as const },
{ key: 'ticketType', label: 'Ticket Type', required: false, type: 'select' as const, options: [
{ value: '1', label: '1 — Service Request' },
{ value: '2', label: '2 — Incident' },
{ value: '3', label: '3 — Problem' },
{ value: '4', label: '4 — Change Request' },
]},
{ key: 'priority', label: 'Priority', required: false, type: 'select' as const, options: [
{ value: '1', label: '1 — Critical' },
{ value: '2', label: '2 — High' },
{ value: '3', label: '3 — Medium' },
{ value: '4', label: '4 — Low' },
]},
{ key: 'status', label: 'Status', required: false, type: 'select' as const, options: [
{ value: '1', label: '1 — New' },
{ value: '5', label: '5 — In Progress' },
]},
{ key: 'queueID', label: 'Queue ID', required: false, type: 'text' as const },
{ key: 'issueType', label: 'Issue Type ID', required: false, type: 'text' as const },
{ key: 'subIssueType', label: 'Sub-Issue Type ID', required: false, type: 'text' as const },
{ key: 'ticketCategory', label: 'Ticket Category', required: false, type: 'select' as const, options: [
{ value: '3', label: '3 — Standard' },
]},
];
// ============================================================================
// Props
// ============================================================================
interface StepConfigEditorProps {
stepType: string;
config: Record<string, any>;
triggerSource: string;
priorSteps: { step_type: string; name: string; config: Record<string, any> }[];
channels: { id: number; name: string; channel_type: string }[];
onChange: (config: Record<string, any>) => void;
}
// ============================================================================
// Variable Picker — shows insertable {{variables}} from trigger + prior steps
// ============================================================================
function VariablePicker({
triggerSource,
priorSteps,
onInsert,
compact = false,
}: {
triggerSource: string;
priorSteps: { step_type: string; name: string; config: Record<string, any> }[];
onInsert: (variable: string) => void;
compact?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const triggerFields = TRIGGER_FIELDS[triggerSource] || [];
// Gather context fields from prior steps
const contextFields: { key: string; label: string; source: string }[] = [];
for (const step of priorSteps) {
const outputs = STEP_OUTPUT_FIELDS[step.step_type] || [];
for (const f of outputs) {
contextFields.push({ ...f, source: step.name });
}
// For transform steps, add the mapping keys
if (step.step_type === 'transform' && step.config.mappings) {
for (const key of Object.keys(step.config.mappings)) {
contextFields.push({ key, label: key, source: step.name });
}
}
// For set_variable, add the key
if (step.step_type === 'set_variable' && step.config.key) {
contextFields.push({ key: step.config.key, label: step.config.key, source: step.name });
}
}
// Group trigger fields
const triggerGroups = new Map<string, typeof triggerFields>();
for (const f of triggerFields) {
const group = triggerGroups.get(f.group) || [];
group.push(f);
triggerGroups.set(f.group, group);
}
if (!isOpen) {
return (
<Button
variant="outline"
size={compact ? 'sm' : 'default'}
className={compact ? 'h-7 text-xs px-2' : ''}
onClick={() => setIsOpen(true)}
>
<Variable className="h-3 w-3 mr-1" />
Insert Variable
</Button>
);
}
return (
<div className="border rounded-lg bg-background shadow-lg p-3 space-y-3 max-h-80 overflow-y-auto">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase">Insert Variable</span>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setIsOpen(false)}>Close</Button>
</div>
{/* Trigger fields */}
{triggerFields.length > 0 && (
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Trigger Payload</div>
{Array.from(triggerGroups.entries()).map(([group, fields]) => (
<div key={group} className="mb-2">
<div className="text-[10px] uppercase text-muted-foreground/60 mb-0.5">{group}</div>
<div className="flex flex-wrap gap-1">
{fields.map(f => (
<button
key={f.key}
className="px-1.5 py-0.5 text-[11px] bg-blue-50 hover:bg-blue-100 text-blue-700 rounded border border-blue-200 cursor-pointer transition-colors"
onClick={() => { onInsert(`{{trigger.${f.key}}}`); setIsOpen(false); }}
title={`{{trigger.${f.key}}}`}
>
{f.label}
</button>
))}
</div>
</div>
))}
</div>
)}
{/* Context fields from prior steps */}
{contextFields.length > 0 && (
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">From Prior Steps</div>
<div className="flex flex-wrap gap-1">
{contextFields.map((f, i) => (
<button
key={`${f.key}-${i}`}
className="px-1.5 py-0.5 text-[11px] bg-green-50 hover:bg-green-100 text-green-700 rounded border border-green-200 cursor-pointer transition-colors"
onClick={() => { onInsert(`{{context.${f.key}}}`); setIsOpen(false); }}
title={`{{context.${f.key}}} — from "${f.source}"`}
>
{f.label}
<span className="text-[9px] text-green-500 ml-1">({f.source})</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
// ============================================================================
// Insertable Input — text input with variable insert button
// ============================================================================
function InsertableInput({
value,
onChange,
placeholder,
triggerSource,
priorSteps,
multiline = false,
rows = 3,
label,
required = false,
}: {
value: string;
onChange: (val: string) => void;
placeholder?: string;
triggerSource: string;
priorSteps: { step_type: string; name: string; config: Record<string, any> }[];
multiline?: boolean;
rows?: number;
label?: string;
required?: boolean;
}) {
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
const handleInsert = (variable: string) => {
const el = inputRef.current;
if (el) {
const start = el.selectionStart || value.length;
const end = el.selectionEnd || value.length;
const newVal = value.substring(0, start) + variable + value.substring(end);
onChange(newVal);
// Restore cursor after the inserted variable
setTimeout(() => {
el.focus();
el.setSelectionRange(start + variable.length, start + variable.length);
}, 0);
} else {
onChange(value + variable);
}
};
return (
<div className="space-y-1">
{label && (
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
{label} {required && <span className="text-red-400">*</span>}
</label>
<VariablePicker triggerSource={triggerSource} priorSteps={priorSteps} onInsert={handleInsert} compact />
</div>
)}
{multiline ? (
<textarea
ref={inputRef as any}
className="w-full px-2.5 py-1.5 text-sm border rounded bg-background font-mono"
rows={rows}
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
/>
) : (
<input
ref={inputRef as any}
className="w-full px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
/>
)}
{/* Show resolved variable badges */}
{value && value.includes('{{') && (
<div className="flex flex-wrap gap-1">
{(value.match(/\{\{[^}]+\}\}/g) || []).map((v, i) => (
<Badge key={i} variant="outline" className="text-[10px] bg-violet-50 text-violet-600 border-violet-200">
{v}
</Badge>
))}
</div>
)}
</div>
);
}
// ============================================================================
// Step-specific editors
// ============================================================================
function TransformEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const mappings: Record<string, string> = config.mappings || {};
const keys = Object.keys(mappings);
const [newKey, setNewKey] = useState('');
const triggerFields = TRIGGER_FIELDS[triggerSource] || [];
const updateMapping = (key: string, value: string) => {
onChange({ ...config, mappings: { ...mappings, [key]: value } });
};
const removeMapping = (key: string) => {
const updated = { ...mappings };
delete updated[key];
onChange({ ...config, mappings: updated });
};
const addMapping = (fieldKey: string) => {
onChange({ ...config, mappings: { ...mappings, [fieldKey]: `{{trigger.${fieldKey}}}` } });
};
const addCustomMapping = () => {
if (!newKey) return;
onChange({ ...config, mappings: { ...mappings, [newKey]: '' } });
setNewKey('');
};
// Fields not yet extracted
const availableFields = triggerFields.filter(f => !mappings[f.key]);
const groups = new Map<string, typeof availableFields>();
for (const f of availableFields) {
const g = groups.get(f.group) || [];
g.push(f);
groups.set(f.group, g);
}
return (
<div className="space-y-4">
{/* Current mappings */}
{keys.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground">Extracted Fields</div>
{keys.map(key => (
<div key={key} className="flex items-center gap-2 bg-muted/50 rounded p-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">{key}</Badge>
<span className="text-muted-foreground text-xs">=</span>
<input
className="flex-1 px-2 py-1 text-xs border rounded bg-background font-mono"
value={mappings[key]}
onChange={e => updateMapping(key, e.target.value)}
/>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => removeMapping(key)}>
<Trash2 className="h-3 w-3 text-red-400" />
</Button>
</div>
))}
</div>
)}
{/* Available fields to extract */}
<div>
<div className="text-xs font-semibold text-muted-foreground mb-2">Available Fields click to extract</div>
{Array.from(groups.entries()).map(([group, fields]) => (
<div key={group} className="mb-2">
<div className="text-[10px] uppercase text-muted-foreground/60 mb-1">{group}</div>
<div className="flex flex-wrap gap-1">
{fields.map(f => (
<button
key={f.key}
className="px-2 py-1 text-xs bg-blue-50 hover:bg-blue-100 text-blue-700 rounded border border-blue-200 cursor-pointer transition-colors"
onClick={() => addMapping(f.key)}
title={`Extract {{trigger.${f.key}}} as context.${f.key}`}
>
<Plus className="h-2.5 w-2.5 inline mr-0.5" />
{f.label}
</button>
))}
</div>
</div>
))}
</div>
{/* Custom field */}
<div className="flex gap-2">
<input
className="flex-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="Custom field name..."
value={newKey}
onChange={e => setNewKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addCustomMapping()}
/>
<Button variant="outline" size="sm" onClick={addCustomMapping} disabled={!newKey}>
<Plus className="h-3 w-3 mr-1" /> Add Custom
</Button>
</div>
</div>
);
}
function FilterEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const conditions: any[] = config.conditions || [];
const addCondition = () => {
onChange({ ...config, conditions: [...conditions, { field: '', operator: 'equals', value: '' }] });
};
const updateCondition = (index: number, updates: any) => {
const updated = conditions.map((c, i) => i === index ? { ...c, ...updates } : c);
onChange({ ...config, conditions: updated });
};
const removeCondition = (index: number) => {
onChange({ ...config, conditions: conditions.filter((_, i) => i !== index) });
};
const allFields = [
...(TRIGGER_FIELDS[triggerSource] || []).map(f => ({ key: `trigger.${f.key}`, label: `Trigger: ${f.label}` })),
...getContextFields(priorSteps).map(f => ({ key: `context.${f.key}`, label: `Context: ${f.label}` })),
];
return (
<div className="space-y-3">
<div className="text-xs font-semibold text-muted-foreground">Conditions (all must match)</div>
{conditions.map((cond, i) => (
<div key={i} className="flex items-center gap-2 bg-muted/50 rounded p-2">
<select
className="px-2 py-1 text-xs border rounded bg-background min-w-[180px]"
value={cond.field}
onChange={e => updateCondition(i, { field: e.target.value })}
>
<option value="">Select field...</option>
{allFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select
className="px-2 py-1 text-xs border rounded bg-background"
value={cond.operator}
onChange={e => updateCondition(i, { operator: e.target.value })}
>
<option value="equals">equals</option>
<option value="not_equals">not equals</option>
<option value="contains">contains</option>
<option value="not_contains">not contains</option>
<option value="regex">regex</option>
<option value="exists">exists</option>
<option value="not_exists">not exists</option>
</select>
{!['exists', 'not_exists'].includes(cond.operator) && (
<input
className="flex-1 px-2 py-1 text-xs border rounded bg-background"
placeholder="Value..."
value={cond.value}
onChange={e => updateCondition(i, { value: e.target.value })}
/>
)}
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => removeCondition(i)}>
<Trash2 className="h-3 w-3 text-red-400" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addCondition}>
<Plus className="h-3 w-3 mr-1" /> Add Condition
</Button>
</div>
);
}
function CreateTicketEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const template: Record<string, any> = config.template || {};
const updateField = (key: string, value: string) => {
onChange({ ...config, template: { ...template, [key]: value } });
};
return (
<div className="space-y-3">
<div className="text-xs font-semibold text-muted-foreground">Ticket Fields</div>
{TICKET_FIELDS.map(field => (
<div key={field.key}>
{field.type === 'select' ? (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{field.label} {field.required && <span className="text-red-400">*</span>}
</label>
<div className="flex gap-2">
<select
className="flex-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={template[field.key] || ''}
onChange={e => updateField(field.key, e.target.value)}
>
<option value=""> Select </option>
{field.options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Allow typing a variable instead */}
<input
className="w-48 px-2 py-1 text-xs border rounded bg-background font-mono"
placeholder="or {{variable}}"
value={typeof template[field.key] === 'string' && template[field.key]?.includes('{{') ? template[field.key] : ''}
onChange={e => updateField(field.key, e.target.value)}
/>
</div>
</div>
) : field.type === 'textarea' ? (
<InsertableInput
label={field.label}
required={field.required}
value={template[field.key] || ''}
onChange={val => updateField(field.key, val)}
placeholder={`Enter ${field.label.toLowerCase()}...`}
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={4}
/>
) : field.type === 'variable' ? (
<InsertableInput
label={field.label}
required={field.required}
value={template[field.key]?.toString() || ''}
onChange={val => updateField(field.key, val)}
placeholder={`{{context.company_id}} or numeric ID`}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
) : (
<InsertableInput
label={field.label}
required={field.required}
value={template[field.key]?.toString() || ''}
onChange={val => updateField(field.key, val)}
placeholder={`Enter ${field.label.toLowerCase()}...`}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
)}
</div>
))}
</div>
);
}
function UpdateTicketEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const fields: Record<string, any> = config.fields || {};
return (
<div className="space-y-3">
<InsertableInput
label="Ticket ID"
required
value={config.ticket_id || ''}
onChange={val => onChange({ ...config, ticket_id: val })}
placeholder="{{context.ticket_id}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<div className="text-xs font-semibold text-muted-foreground">Fields to Update</div>
{TICKET_FIELDS.filter(f => f.key !== 'title' && f.key !== 'description').map(field => (
<InsertableInput
key={field.key}
label={field.label}
value={fields[field.key]?.toString() || ''}
onChange={val => onChange({ ...config, fields: { ...fields, [field.key]: val || undefined } })}
placeholder={`Leave blank to skip`}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
))}
</div>
);
}
function CreateNoteEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<InsertableInput
label="Ticket ID"
required
value={config.ticket_id || ''}
onChange={val => onChange({ ...config, ticket_id: val })}
placeholder="{{context.ticket_id}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Note Title"
value={config.title || ''}
onChange={val => onChange({ ...config, title: val })}
placeholder="Pipeline Note"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Note Body"
value={config.body || ''}
onChange={val => onChange({ ...config, body: val })}
placeholder="Note content..."
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={5}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Note Type</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.note_type || '1'}
onChange={e => onChange({ ...config, note_type: Number(e.target.value) })}
>
<option value="1">Internal</option>
<option value="2">External</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Publish</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.publish || '1'}
onChange={e => onChange({ ...config, publish: Number(e.target.value) })}
>
<option value="1">Yes</option>
<option value="0">No</option>
</select>
</div>
</div>
</div>
);
}
function EnrichEditor({ config, onChange, triggerSource, priorSteps, stepType }: StepConfigEditorProps & { stepType: string }) {
const lookupOptions: Record<string, { key: string; label: string }[]> = {
enrich_device: [{ key: 'device_uid', label: 'Device UID' }],
enrich_company: [
{ key: 'site_name', label: 'Site Name' },
{ key: 'site_uid', label: 'Site UID' },
],
enrich_ticket: [
{ key: 'ticket_number', label: 'Ticket Number' },
{ key: 'ticket_id', label: 'Ticket ID' },
],
};
const options = lookupOptions[stepType] || [];
const outputs = STEP_OUTPUT_FIELDS[stepType] || [];
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Lookup By</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.lookup_by || options[0]?.key || ''}
onChange={e => onChange({ ...config, lookup_by: e.target.value })}
>
{options.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
</div>
<InsertableInput
label="Source Value"
required
value={config.source_field || ''}
onChange={val => onChange({ ...config, source_field: val })}
placeholder={`{{trigger.${config.lookup_by || options[0]?.key || 'field'}}}`}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
{outputs.length > 0 && (
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Output Fields (added to context)</div>
<div className="flex flex-wrap gap-1">
{outputs.map(f => (
<Badge key={f.key} variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{f.label} context.{f.key}
</Badge>
))}
</div>
</div>
)}
</div>
);
}
function NotifyEditor({ config, onChange, channels, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Channel <span className="text-red-400">*</span></label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.channel_id || ''}
onChange={e => onChange({ ...config, channel_id: Number(e.target.value) })}
>
<option value="">Select channel...</option>
{channels.map(ch => (
<option key={ch.id} value={ch.id}>{ch.name} ({ch.channel_type})</option>
))}
</select>
{channels.length === 0 && (
<p className="text-xs text-muted-foreground mt-1">
No channels configured. <a href="/admin/workflow/channels" className="text-blue-500 underline">Add one</a>
</p>
)}
</div>
<InsertableInput
label="Title"
value={config.title || ''}
onChange={val => onChange({ ...config, title: val })}
placeholder="Notification title"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Message"
required
value={config.message || ''}
onChange={val => onChange({ ...config, message: val })}
placeholder="Notification message..."
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={4}
/>
</div>
);
}
function ApprovalEditor({ config, onChange, channels, triggerSource, priorSteps }: StepConfigEditorProps) {
const options: string[] = config.options || ['Approve', 'Reject'];
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Channel</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.channel_id || ''}
onChange={e => onChange({ ...config, channel_id: Number(e.target.value) })}
>
<option value="">Select channel...</option>
{channels.map(ch => (
<option key={ch.id} value={ch.id}>{ch.name} ({ch.channel_type})</option>
))}
</select>
</div>
<InsertableInput
label="Message"
required
value={config.message || ''}
onChange={val => onChange({ ...config, message: val })}
placeholder="Approval request message..."
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={3}
/>
<div>
<label className="text-xs font-medium text-muted-foreground">Options</label>
<div className="flex flex-wrap gap-2 mt-1">
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1 bg-muted rounded px-2 py-1">
<input
className="px-1.5 py-0.5 text-xs border rounded bg-background w-24"
value={opt}
onChange={e => {
const updated = [...options];
updated[i] = e.target.value;
onChange({ ...config, options: updated });
}}
/>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => {
onChange({ ...config, options: options.filter((_, j) => j !== i) });
}}>
<Trash2 className="h-2.5 w-2.5 text-red-400" />
</Button>
</div>
))}
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => {
onChange({ ...config, options: [...options, 'Option'] });
}}>
<Plus className="h-2.5 w-2.5 mr-1" /> Add
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Timeout (minutes)</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
type="number"
value={config.timeout_min || 60}
onChange={e => onChange({ ...config, timeout_min: Number(e.target.value) })}
/>
</div>
</div>
);
}
function AiAnalyzeEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<InsertableInput
label="System Prompt"
value={config.system_prompt || ''}
onChange={val => onChange({ ...config, system_prompt: val })}
placeholder="You are a helpful IT operations assistant."
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={2}
/>
<InsertableInput
label="Prompt"
required
value={config.prompt || ''}
onChange={val => onChange({ ...config, prompt: val })}
placeholder="Summarize this alert..."
triggerSource={triggerSource}
priorSteps={priorSteps}
multiline
rows={5}
/>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Provider</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.provider || ''}
onChange={e => onChange({ ...config, provider: e.target.value || undefined })}
>
<option value="">Default</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Model</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="Default from settings"
value={config.model || ''}
onChange={e => onChange({ ...config, model: e.target.value || undefined })}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Max Tokens</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
type="number"
placeholder="2000"
value={config.max_tokens || ''}
onChange={e => onChange({ ...config, max_tokens: Number(e.target.value) || undefined })}
/>
</div>
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Output Fields</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">ai_response</Badge>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">ai_provider</Badge>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">ai_model</Badge>
</div>
</div>
</div>
);
}
function RmmQuickJobEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<InsertableInput
label="Device UID"
required
value={config.device_uid || ''}
onChange={val => onChange({ ...config, device_uid: val })}
placeholder="{{context.device_uid}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Component UID"
required
value={config.component_uid || ''}
onChange={val => onChange({ ...config, component_uid: val })}
placeholder="comp-xxx-yyy"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Job Name"
value={config.job_name || ''}
onChange={val => onChange({ ...config, job_name: val })}
placeholder="Quick Job Name"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<p className="text-xs text-muted-foreground">
Use <code className="bg-muted px-1 rounded">GET /api/rmm/components</code> to find available component UIDs.
</p>
</div>
);
}
function SetVariableEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Variable Name <span className="text-red-400">*</span></label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="my_variable"
value={config.key || ''}
onChange={e => onChange({ ...config, key: e.target.value })}
/>
</div>
<InsertableInput
label="Value"
required
value={config.value || ''}
onChange={val => onChange({ ...config, value: val })}
placeholder="Static value or {{variable}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
</div>
);
}
function DelayEditor({ config, onChange }: StepConfigEditorProps) {
return (
<div>
<label className="text-xs font-medium text-muted-foreground">Delay (seconds)</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
type="number"
min={0}
value={config.seconds || 0}
onChange={e => onChange({ ...config, seconds: Number(e.target.value) })}
/>
</div>
);
}
function RmmGetJobResultsEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
return (
<div className="space-y-3">
<InsertableInput
label="Job UID"
required
value={config.job_uid || ''}
onChange={val => onChange({ ...config, job_uid: val })}
placeholder="{{context.job_uid}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<InsertableInput
label="Device UID"
required
value={config.device_uid || ''}
onChange={val => onChange({ ...config, device_uid: val })}
placeholder="{{context.device_uid}}"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
</div>
);
}
// ============================================================================
// Helper
// ============================================================================
function getContextFields(priorSteps: { step_type: string; name: string; config: Record<string, any> }[]) {
const fields: { key: string; label: string }[] = [];
for (const step of priorSteps) {
const outputs = STEP_OUTPUT_FIELDS[step.step_type] || [];
fields.push(...outputs);
if (step.step_type === 'transform' && step.config.mappings) {
for (const key of Object.keys(step.config.mappings)) {
fields.push({ key, label: key });
}
}
if (step.step_type === 'set_variable' && step.config.key) {
fields.push({ key: step.config.key, label: step.config.key });
}
if (step.step_type === 'db_query' && step.config.output_key) {
fields.push({ key: step.config.output_key, label: `${step.config.output_key} (query)` });
fields.push({ key: `${step.config.output_key}_count`, label: `${step.config.output_key}_count` });
}
if (step.step_type === 'fetch_b2_result' && step.config.output_key) {
fields.push({ key: step.config.output_key, label: `${step.config.output_key} (B2 JSON)` });
fields.push({ key: `${step.config.output_key}_raw_length`, label: `${step.config.output_key}_raw_length` });
fields.push({ key: 'b2_object_key', label: 'b2_object_key' });
}
}
return fields;
}
function EnrichVspcEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const outputs = STEP_OUTPUT_FIELDS['enrich_vspc'] || [];
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Lookup By</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.lookup_by || 'device_name'}
onChange={e => onChange({ ...config, lookup_by: e.target.value })}
>
<option value="device_name">Device Name (hostname)</option>
<option value="organization_uid">Organization UID</option>
</select>
</div>
<InsertableInput
label="Source Value"
required
value={config.source_field || ''}
onChange={val => onChange({ ...config, source_field: val })}
placeholder={config.lookup_by === 'organization_uid' ? '{{context.org_uid}}' : '{{trigger.device_hostname}}'}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<div className="bg-blue-50 border border-blue-200 rounded p-3 space-y-2">
<div className="text-xs font-semibold text-blue-700">What this step does</div>
<ul className="text-xs text-blue-600 space-y-0.5 list-disc pl-4">
<li>Queries local Veeam VSPC data for backup agent jobs matching the device</li>
<li>Checks backup server jobs and protected workloads (VM-level)</li>
<li>Retrieves active VSPC alarms for the device</li>
<li>Calculates hours since last successful backup</li>
<li>Builds a text summary of all findings</li>
</ul>
</div>
{outputs.length > 0 && (
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Output Fields (added to context)</div>
<div className="flex flex-wrap gap-1">
{outputs.map(f => (
<Badge key={f.key} variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{f.label} context.{f.key}
</Badge>
))}
</div>
</div>
)}
</div>
);
}
function DbQueryEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const outputKey = config.output_key || 'query_result';
return (
<div className="space-y-3">
<div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">SQL Query <span className="text-red-400">*</span></label>
<span className="text-[10px] text-muted-foreground">Read-only SELECT only. Use $1, $2 for params.</span>
</div>
<textarea
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background font-mono"
rows={6}
placeholder={`SELECT status, failure_message, last_run\nFROM veeam_backup_agent_jobs\nWHERE LOWER(name) LIKE LOWER($1)\nORDER BY last_run DESC LIMIT 10`}
value={config.query || ''}
onChange={e => onChange({ ...config, query: e.target.value })}
/>
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Query Parameters</div>
{(config.params || []).map((param: string, i: number) => (
<div key={i} className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="font-mono text-xs shrink-0">${i + 1}</Badge>
<div className="flex-1">
<InsertableInput
value={param}
onChange={val => {
const updated = [...(config.params || [])];
updated[i] = val;
onChange({ ...config, params: updated });
}}
placeholder={`Parameter $${i + 1} value`}
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => {
const updated = (config.params || []).filter((_: any, j: number) => j !== i);
onChange({ ...config, params: updated });
}}>
<Trash2 className="h-3 w-3 text-red-400" />
</Button>
</div>
))}
<Button variant="outline" size="sm" className="mt-1" onClick={() => {
onChange({ ...config, params: [...(config.params || []), ''] });
}}>
<Plus className="h-3 w-3 mr-1" /> Add Parameter
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Output Key</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="query_result"
value={config.output_key || ''}
onChange={e => onChange({ ...config, output_key: e.target.value || undefined })}
/>
<p className="text-[10px] text-muted-foreground mt-0.5">Results stored as context.{outputKey}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Result Mode</label>
<select
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
value={config.single_row ? 'single' : 'array'}
onChange={e => onChange({ ...config, single_row: e.target.value === 'single' })}
>
<option value="array">All rows (array)</option>
<option value="single">First row only (object)</option>
</select>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded p-2">
<div className="text-xs text-amber-700">
<strong>Security:</strong> Only SELECT queries allowed. INSERT, UPDATE, DELETE, DROP, etc. are blocked.
</div>
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Output Fields</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{outputKey} query rows
</Badge>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{outputKey}_count row count
</Badge>
</div>
</div>
</div>
);
}
// ============================================================================
// Fetch B2 Result Editor
// ============================================================================
function FetchB2ResultEditor({ config, onChange, triggerSource, priorSteps }: StepConfigEditorProps) {
const outputKey = config.output_key || 'b2_result';
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Object Key</label>
<InsertableInput
value={config.object_key || ''}
onChange={v => onChange({ ...config, object_key: v })}
placeholder="diagnostics/{{context.device_hostname}}/result.json"
triggerSource={triggerSource}
priorSteps={priorSteps}
/>
<p className="text-[10px] text-muted-foreground mt-0.5">S3 object key in the B2 bucket. Use template variables from prior steps.</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Output Key</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="diagnostic_results"
value={config.output_key || ''}
onChange={e => onChange({ ...config, output_key: e.target.value || undefined })}
/>
<p className="text-[10px] text-muted-foreground mt-0.5">Parsed JSON stored as context.{outputKey}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Bucket</label>
<input
className="w-full mt-1 px-2.5 py-1.5 text-sm border rounded bg-background"
placeholder="wulf-audits (default)"
value={config.bucket || ''}
onChange={e => onChange({ ...config, bucket: e.target.value || undefined })}
/>
<p className="text-[10px] text-muted-foreground mt-0.5">Leave blank for default B2_BUCKET</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded p-2">
<div className="text-xs text-blue-700">
<strong>How it works:</strong> Generates a presigned S3 URL, downloads the file, and parses JSON into the pipeline context.
</div>
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground mb-1">Output Fields</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{outputKey} parsed JSON
</Badge>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
{outputKey}_raw_length byte count
</Badge>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-600 border-green-200">
b2_object_key key used
</Badge>
</div>
</div>
</div>
);
}
// ============================================================================
// Main Export — routes to the correct editor
// ============================================================================
export default function StepConfigEditor(props: StepConfigEditorProps) {
const { stepType } = props;
switch (stepType) {
case 'transform':
return <TransformEditor {...props} />;
case 'filter':
return <FilterEditor {...props} />;
case 'create_ticket':
return <CreateTicketEditor {...props} />;
case 'update_ticket':
return <UpdateTicketEditor {...props} />;
case 'create_note':
return <CreateNoteEditor {...props} />;
case 'enrich_device':
case 'enrich_company':
case 'enrich_ticket':
return <EnrichEditor {...props} stepType={stepType} />;
case 'notify':
return <NotifyEditor {...props} />;
case 'approval':
return <ApprovalEditor {...props} />;
case 'ai_analyze':
return <AiAnalyzeEditor {...props} />;
case 'rmm_quick_job':
return <RmmQuickJobEditor {...props} />;
case 'rmm_get_job_results':
return <RmmGetJobResultsEditor {...props} />;
case 'set_variable':
return <SetVariableEditor {...props} />;
case 'delay':
return <DelayEditor {...props} />;
case 'enrich_vspc':
return <EnrichVspcEditor {...props} />;
case 'db_query':
return <DbQueryEditor {...props} />;
case 'fetch_b2_result':
return <FetchB2ResultEditor {...props} />;
default:
// Fallback: raw JSON editor
return (
<div>
<label className="text-xs font-medium text-muted-foreground">Config (JSON)</label>
<textarea
className="w-full mt-1 px-2 py-1.5 text-sm border rounded bg-background font-mono"
rows={6}
value={JSON.stringify(props.config, null, 2)}
onChange={e => {
try { props.onChange(JSON.parse(e.target.value)); } catch {}
}}
/>
</div>
);
}
}
export { VariablePicker, InsertableInput, TRIGGER_FIELDS, STEP_OUTPUT_FIELDS };